Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/customer-portal/backend/Dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ dependencies = [
{org = "ballerina", name = "task"},
{org = "ballerina", name = "time"}
]
modules = [
{org = "ballerina", packageName = "cache", moduleName = "cache"}
]

[[package]]
org = "ballerina"
Expand Down Expand Up @@ -347,6 +350,7 @@ org = "wso2"
name = "customer_portal"
version = "1.0.0-rc.3"
dependencies = [
{org = "ballerina", name = "cache"},
{org = "ballerina", name = "constraint"},
{org = "ballerina", name = "http"},
{org = "ballerina", name = "jwt"},
Expand Down
19 changes: 13 additions & 6 deletions apps/customer-portal/backend/modules/entity/types.bal
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ public type Pagination record {|
int offset = DEFAULT_OFFSET;
# Limit for pagination
@constraint:Int {
minValue: 1,
maxValue: 50
minValue: 1
}
int 'limit = DEFAULT_LIMIT;
json...;
Expand Down Expand Up @@ -1407,6 +1406,10 @@ public type ProductVulnerabilitySearchPayload record {|
int statusId?;
# Severity ID
int severityId?;
# Product name filter
string productName?;
# Product version filter
string productVersion?;
} filters?;
# Sort configuration
SortBy sortBy?; // TODO: Check the correct sort by fields for vulnerabilities
Expand All @@ -1424,12 +1427,20 @@ public type ProductVulnerability record {|
string vulnerabilityId;
# Severity level
ChoiceListItem severity;
# Name of the product
string productName?;
# Version of the product
string productVersion?;
# Name of the component
string componentName;
# Version of the component
string version;
# Type
string 'type;
# Type of the component
string componentType?;
# Update level for the vulnerability
string updateLevel?;
# Use case description
string? useCase;
# Justification for the vulnerability
Expand All @@ -1442,10 +1453,6 @@ public type ProductVulnerability record {|
# Product vulnerability information.
public type ProductVulnerabilityResponse record {|
*ProductVulnerability;
# Type of the component
string componentType?;
# Update level for the vulnerability
string updateLevel;
json...;
|};

Expand Down
16 changes: 12 additions & 4 deletions apps/customer-portal/backend/modules/types/types.bal
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,10 @@ public type ProductVulnerabilitySearchFilters record {|
int statusId?;
# Severity ID
int severityId?;
# Product name filter
string productName?;
# Product version filter
string productVersion?;
|};

# Base product vulnerability.
Expand All @@ -826,12 +830,20 @@ public type ProductVulnerability record {|
string vulnerabilityId;
# Severity level
ReferenceItem severity;
# Name of the product
string productName?;
# Version of the product
string productVersion?;
# Name of the component
string componentName;
# Version of the component
string version;
# Type
string 'type;
# Type of the component
string componentType?;
# Update level for the vulnerability
string updateLevel?;
# Use case description
string? useCase;
# Justification
Expand All @@ -844,10 +856,6 @@ public type ProductVulnerability record {|
# Product vulnerability information.
public type ProductVulnerabilityResponse record {|
*ProductVulnerability;
# Type of the component
string componentType?;
# Update level for the vulnerability
string updateLevel?;
|};

# Product vulnerabilities response with pagination.
Expand Down
26 changes: 18 additions & 8 deletions apps/customer-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4636,9 +4636,21 @@ components:
description: Vulnerability identifier
severity:
$ref: '#/components/schemas/ReferenceItem'
productName:
type: string
description: Name of the product
productVersion:
type: string
description: Version of the product
Comment thread
coderabbitai[bot] marked this conversation as resolved.
componentName:
type: string
description: Name of the component
componentType:
type: string
description: Type of the component
updateLevel:
type: string
description: Update level for the vulnerability
version:
type: string
description: Version of the component
Expand Down Expand Up @@ -4674,14 +4686,6 @@ components:
description: Product vulnerability information.
allOf:
- $ref: '#/components/schemas/ProductVulnerability'
- type: object
properties:
componentType:
type: string
description: Type of the component
updateLevel:
type: string
description: Update level for the vulnerability
ProductVulnerabilitySearchPayload:
type: object
properties:
Expand All @@ -4700,6 +4704,12 @@ components:
type: integer
description: Severity ID
format: int64
productName:
type: string
description: Product name
productVersion:
type: string
description: Product version
description: Filter criteria
sortBy:
$ref: '#/components/schemas/SortBy'
Expand Down
142 changes: 142 additions & 0 deletions apps/customer-portal/backend/service.bal
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import customer_portal.types;
import customer_portal.updates;
import customer_portal.user_management;

import ballerina/cache;
import ballerina/http;
import ballerina/log;
import ballerina/time;
Expand Down Expand Up @@ -52,6 +53,15 @@ service class ErrorInterceptor {
}
}

// In-memory cache for bulk product-vulnerability fetches (limit > 50).
// The dataset is org-wide — identical for every authenticated user — so a single
// shared cache is safe. TTL: 12 hours.
final cache:Cache productVulnerabilityCache = new ({
capacity: 20,
evictionFactor: 0.2,
defaultMaxAge: 12 * 60 * 60
});

configurable int wsPort = 9091;

http:ListenerConfiguration listenerConf = {
Expand Down Expand Up @@ -2971,6 +2981,138 @@ service http:InterceptableService / on new http:Listener(9090, listenerConf) {
};
}

int reqLimit = payload.pagination?.'limit ?: 10;

// The entity service has a hard limit of 50 records per request.
// When the caller requests more than 50 (e.g. a "fetch all" call from the frontend),
// we transparently batch through the entity service and aggregate the results here.
if reqLimit > 50 {
int reqOffset = payload.pagination?.offset ?: 0;

// Cache key covers all filter and sort dimensions so distinct queries
// never share a cache entry.
string cacheKey = string `sq=${payload.filters?.searchQuery ?: ""},` +
string `sv=${payload.filters?.severityId ?: ""},` +
string `st=${payload.filters?.statusId ?: ""},` +
string `pn=${payload.filters?.productName ?: ""},` +
string `pv=${payload.filters?.productVersion ?: ""},` +
string `sb=${payload.sortBy?.'field ?: ""}-${payload.sortBy?.'order ?: ""}`;

// Return cached result if available (TTL: 12 hours).
any|cache:Error cachedEntry = productVulnerabilityCache.get(cacheKey);
if cachedEntry is types:ProductVulnerability[] {
log:printDebug(string `[vulnerabilities] Cache hit for key: ${cacheKey}`);
int totalCached = cachedEntry.length();
types:ProductVulnerability[] cachedPage = reqOffset >= totalCached
? []
: (reqOffset + reqLimit >= totalCached
? cachedEntry.slice(reqOffset)
: cachedEntry.slice(reqOffset, reqOffset + reqLimit));
return <http:Ok>{
body: <types:ProductVulnerabilitySearchResponse>{
productVulnerabilities: cachedPage,
totalRecords: totalCached,
'limit: reqLimit,
offset: reqOffset
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// First call with limit=1 to get the total number of available records.
entity:ProductVulnerabilitySearchPayload countPayload = {
filters: payload.filters,
sortBy: payload.sortBy,
pagination: {offset: 0, 'limit: 1}
};

entity:ProductVulnerabilitySearchResponse|error countResponse =
entity:searchProductVulnerabilities(userInfo.idToken, countPayload);
if countResponse is error {
if getStatusCode(countResponse) == http:STATUS_FORBIDDEN {
log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${
userInfo.userId}`);
return <http:Forbidden>{
body: {
message: "Access to product vulnerabilities information is forbidden for the user!"
}
};
}
string customError = "Failed to search product vulnerabilities.";
log:printError(customError, countResponse);
return <http:InternalServerError>{body: {message: customError}};
}

int totalFromEntity = countResponse.totalRecords;
if totalFromEntity == 0 {
return <http:Ok>{
body: <types:ProductVulnerabilitySearchResponse>{
productVulnerabilities: [],
totalRecords: 0,
'limit: reqLimit,
offset: reqOffset
}
};
}

// Fetch all records in batches of 50.
int batchSize = 50;
int batchCount = (totalFromEntity + batchSize - 1) / batchSize;
types:ProductVulnerability[] allVulnerabilities = [];

foreach int batchIndex in 0 ..< batchCount {
entity:ProductVulnerabilitySearchPayload fetchPayload = {
filters: payload.filters,
sortBy: payload.sortBy,
pagination: {offset: batchIndex * batchSize, 'limit: batchSize}
};

entity:ProductVulnerabilitySearchResponse|error batchResponse =
entity:searchProductVulnerabilities(userInfo.idToken, fetchPayload);
if batchResponse is error {
if getStatusCode(batchResponse) == http:STATUS_FORBIDDEN {
log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${
userInfo.userId}`);
return <http:Forbidden>{
body: {
message: "Access to product vulnerabilities information is forbidden for the user!"
}
};
}
string customError = "Failed to search product vulnerabilities.";
log:printError(customError, batchResponse);
return <http:InternalServerError>{body: {message: customError}};
}

types:ProductVulnerabilitySearchResponse mappedBatch =
mapProductVulnerabilitySearchResponse(batchResponse);
foreach var v in mappedBatch.productVulnerabilities {
allVulnerabilities.push(v);
}
}

// Store the full aggregated set in cache for 12 hours.
cache:Error? putErr = productVulnerabilityCache.put(cacheKey, allVulnerabilities);
if putErr is cache:Error {
log:printWarn("Failed to store product vulnerabilities in cache.", putErr);
}

int totalAll = allVulnerabilities.length();
types:ProductVulnerability[] responsePage = reqOffset >= totalAll
? []
: (reqOffset + reqLimit >= totalAll
? allVulnerabilities.slice(reqOffset)
: allVulnerabilities.slice(reqOffset, reqOffset + reqLimit));

return <http:Ok>{
body: <types:ProductVulnerabilitySearchResponse>{
productVulnerabilities: responsePage,
totalRecords: totalAll,
'limit: reqLimit,
offset: reqOffset
}
};
}

entity:ProductVulnerabilitySearchResponse|error response =
entity:searchProductVulnerabilities(userInfo.idToken, payload);
if response is error {
Expand Down
6 changes: 6 additions & 0 deletions apps/customer-portal/backend/utils.bal
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,13 @@ public isolated function mapProductVulnerabilitySearchResponse(entity:ProductVul
cveId: vulnerability.cveId,
vulnerabilityId: vulnerability.vulnerabilityId,
severity: {id: vulnerability.severity.id.toString(), label: vulnerability.severity.label},
productName: vulnerability.productName,
productVersion: vulnerability.productVersion,
componentName: vulnerability.componentName,
version: vulnerability.version,
'type: vulnerability.'type,
componentType: vulnerability.componentType,
updateLevel: vulnerability.updateLevel,
useCase: vulnerability.useCase,
justification: vulnerability.justification,
resolution: vulnerability.resolution
Expand All @@ -401,6 +405,8 @@ public isolated function mapProductVulnerabilityResponse(entity:ProductVulnerabi
cveId: response.cveId,
vulnerabilityId: response.vulnerabilityId,
severity: {id: response.severity.id.toString(), label: response.severity.label},
productName: response.productName,
productVersion: response.productVersion,
componentName: response.componentName,
version: response.version,
'type: response.'type,
Expand Down
Loading