Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ const laboratoryService = new LaboratoryService();
const omicsService = new OmicsService();
const laboratoryWorkflowAccessService = new LaboratoryWorkflowAccessService();

/**
* Retrieves every PRIVATE workflow across all pages so the workflow-access filter
* is applied to the complete set. Fetching only a single page would hide any
* granted workflow that happens to fall beyond the first page from every user,
* even though it appears in the (fully paginated) admin workflow catalog.
*/
async function listAllPrivateWorkflows(name?: string) {
const items: NonNullable<Awaited<ReturnType<OmicsService['listWorkflows']>>['items']> = [];
let nextToken: string | undefined;
do {
const page = await omicsService.listWorkflows(<ListWorkflowsCommandInput>{
type: 'PRIVATE',
maxResults: 100,
startingToken: nextToken,
status: undefined, // Explicitly exclude status filter for Workflows
...(name ? { name } : {}),
});
if (page.items?.length) {
items.push(...page.items);
}
nextToken = page.nextToken;
} while (nextToken);
return items;
}

/**
* This GET /aws-healthomics/workflow/list-private-workflows?laboratoryId={LaboratoryId}
* API queries the same region's AWS HealthOmics service to retrieve a list of
Expand Down Expand Up @@ -68,18 +93,14 @@ export const handler: Handler = async (
}

const queryParameters: AwsHealthOmicsQueryParameters = getAwsHealthOmicsApiQueryParameters(event);
const response = await omicsService.listWorkflows(<ListWorkflowsCommandInput>{
type: 'PRIVATE',
...queryParameters,
status: undefined, // Explicitly exclude status filter for Workflows
});
const allItems = await listAllPrivateWorkflows(queryParameters.name);

const accessRows = await laboratoryWorkflowAccessService.listByLaboratoryId(laboratoryId);
const items = (response.items ?? []).filter(
const items = allItems.filter(
(w) => w.id != null && isWorkflowAccessAllowed(laboratory, accessRows, 'HEALTH_OMICS', w.id),
);

return buildResponse(200, JSON.stringify({ ...response, items }), event);
return buildResponse(200, JSON.stringify({ items }), event);
} catch (err: any) {
console.error(err);
return buildErrorResponse(err, event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,60 @@ const laboratoryService = new LaboratoryService();
const ssmService = new SsmService();
const laboratoryWorkflowAccessService = new LaboratoryWorkflowAccessService();

/**
* Retrieves every Seqera pipeline across all pages (preserving any caller-supplied
* search / workspaceId params) so the workflow-access filter is applied to the full
* set rather than a single page.
*/
async function listAllPipelines(
seqeraApiBaseUrl: string,
apiQueryParameters: string,
accessToken: string,
): Promise<ListPipelinesResponse> {
const pipelines: NonNullable<ListPipelinesResponse['pipelines']> = [];
const seen = new Set<string>();
let firstResponse: ListPipelinesResponse | undefined;
let offset = 0;
const max = 100;

for (;;) {
const params = new URLSearchParams(apiQueryParameters);
params.set('max', String(max));
params.set('offset', String(offset));

const response: ListPipelinesResponse = await httpRequest<ListPipelinesResponse>(
`${seqeraApiBaseUrl}/pipelines?${params.toString()}`,
REST_API_METHOD.GET,
{ Authorization: `Bearer ${accessToken}` },
);
if (!firstResponse) {
firstResponse = response;
}

const page = response.pipelines ?? [];
if (!page.length) {
break;
}
for (const p of page) {
const id = p.pipelineId != null ? String(p.pipelineId) : undefined;
if (id != null && seen.has(id)) {
continue;
}
if (id != null) {
seen.add(id);
}
pipelines.push(p);
}

offset += page.length;
if (page.length < max || (response.totalSize != null && offset >= response.totalSize)) {
break;
}
}

return { ...(firstResponse ?? {}), pipelines, totalSize: pipelines.length };
}

/**
* This GET /nf-tower/pipeline/list-pipelines?laboratoryId={LaboratoryId} API
* queries the NextFlow Tower GET /pipelines?workspaceId={WorkspaceId} API for a
Expand Down Expand Up @@ -90,20 +144,21 @@ export const handler: Handler = async (

// Get Seqera API Base URL for Laboratory or default to platform-wide configured Seqera API Base URL
const seqeraApiBaseUrl: string = laboratory.NextFlowTowerApiBaseUrl || process.env.SEQERA_API_BASE_URL;
// Get Query Parameters for Seqera Cloud / NextFlow Tower APIs
// Get Query Parameters for Seqera Cloud / NextFlow Tower APIs (preserves search + workspaceId)
const apiQueryParameters: string = getNextFlowApiQueryParameters(event, laboratory.NextFlowTowerWorkspaceId);
const response: ListPipelinesResponse = await httpRequest<ListPipelinesResponse>(
`${seqeraApiBaseUrl}/pipelines?${apiQueryParameters}`,
REST_API_METHOD.GET,
{ Authorization: `Bearer ${accessToken}` },
);

// Fetch every page of pipelines before applying the workflow-access filter.
// Filtering only the first page would hide any granted pipeline beyond that
// page from every user, even though it appears in the (fully paginated)
// admin workflow catalog.
const response: ListPipelinesResponse = await listAllPipelines(seqeraApiBaseUrl, apiQueryParameters, accessToken);

const accessRows = await laboratoryWorkflowAccessService.listByLaboratoryId(laboratoryId);
const pipelines = (response.pipelines ?? []).filter(
(p) => p.pipelineId != null && isWorkflowAccessAllowed(laboratory, accessRows, 'SEQERA', String(p.pipelineId)),
);

return buildResponse(200, JSON.stringify({ ...response, pipelines }), event);
return buildResponse(200, JSON.stringify({ ...response, pipelines, totalSize: pipelines.length }), event);
} catch (err: any) {
console.error(err);
return buildErrorResponse(err, event);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { UnifiedWorkflowCatalogEntry, LaboratoryWorkflowAccessPlatform } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/laboratory-workflow-access';
import type {
UnifiedWorkflowCatalogEntry,
LaboratoryWorkflowAccessPlatform,
} from '@easy-genomics/shared-lib/src/app/types/easy-genomics/laboratory-workflow-access';
import { LaboratoryWorkflowAccessService } from '@BE/services/easy-genomics/laboratory-workflow-access-service';
import { buildUnifiedWorkflowCatalogForOrganization } from '@BE/services/easy-genomics/unified-workflow-catalog-service';
import { parseWorkflowAccessSortKey, rowIsAllow, rowIsDeny } from '@BE/utils/laboratory-workflow-access-utils';
import { parseWorkflowAccessSortKey, rowIsDeny } from '@BE/utils/laboratory-workflow-access-utils';

const CHUNK = 20;

Expand Down Expand Up @@ -31,116 +34,81 @@ export async function migrateWorkflowAccessOnDefaultModeChange(params: {
}

const accessService = new LaboratoryWorkflowAccessService();
const catalog = await buildUnifiedWorkflowCatalogForOrganization(organizationId);
const catalogKeySet = new Set(catalog.map(catalogEntryToApiKey));

const rows = await accessService.listByLaboratoryId(laboratoryId);

if (!previousDefaultOn && nextDefaultOn) {
const allowKeys = new Set<string>();
// Strict -> default-on: as documented in the admin UI, turning "enable new
// workflows by default" on means workflows are allowed unless explicitly
// denied. Any rows left over from strict mode must be cleared to land on a
// clean "allowed by default" state:
// - strict-mode ALLOW rows are redundant here (default-on ignores ALLOW), and
// - DENY rows are inert under strict mode, so any that exist are stale/spurious
// (e.g. written by an earlier strict->on migration) and would otherwise hide
// every workflow from all lab users despite the toggle reading "on".
// We deliberately do NOT create DENY rows for the catalog here — doing so was
// the cause of "default on, but nobody can see any workflows".
for (const row of rows) {
if (!rowIsAllow(row)) {
continue;
}
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
allowKeys.add(`${parsed.platform}::${parsed.workflowId}`);
await accessService.remove(laboratoryId, parsed.platform, parsed.workflowId);
}
return;
}

const denyUpserts: UnifiedWorkflowCatalogEntry[] = [];
for (const entry of catalog) {
const k = catalogEntryToApiKey(entry);
if (!allowKeys.has(k)) {
denyUpserts.push(entry);
}
}
// default-on -> strict: preserve current visibility by ALLOW-listing every
// catalog workflow that isn't explicitly denied, then drop the DENY rows so the
// lab does not suddenly hide everything when switching into strict mode.
const catalog = await buildUnifiedWorkflowCatalogForOrganization(organizationId);

for (let i = 0; i < denyUpserts.length; i += CHUNK) {
const slice = denyUpserts.slice(i, i + CHUNK);
await Promise.all(
slice.map((entry) => {
const plat: LaboratoryWorkflowAccessPlatform = entry.platform === 'HealthOmics' ? 'HEALTH_OMICS' : 'SEQERA';
return accessService.upsert({
LaboratoryId: laboratoryId,
WorkflowKey: `${plat}#${entry.workflowId}`,
OrganizationId: organizationId,
WorkflowName: entry.name,
Effect: 'DENY',
});
}),
);
if (i + CHUNK < denyUpserts.length) {
await sleep(0);
}
const denyKeys = new Set<string>();
for (const row of rows) {
if (!rowIsDeny(row)) {
continue;
}

for (const row of rows) {
if (!rowIsAllow(row)) {
continue;
}
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
const k = `${parsed.platform}::${parsed.workflowId}`;
if (catalogKeySet.has(k)) {
await accessService.remove(laboratoryId, parsed.platform, parsed.workflowId);
}
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
return;
denyKeys.add(`${parsed.platform}::${parsed.workflowId}`);
}

if (previousDefaultOn && !nextDefaultOn) {
const denyKeys = new Set<string>();
for (const row of rows) {
if (!rowIsDeny(row)) {
continue;
}
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
denyKeys.add(`${parsed.platform}::${parsed.workflowId}`);
const allowUpserts: UnifiedWorkflowCatalogEntry[] = [];
for (const entry of catalog) {
const k = catalogEntryToApiKey(entry);
if (!denyKeys.has(k)) {
allowUpserts.push(entry);
}
}

const allowUpserts: UnifiedWorkflowCatalogEntry[] = [];
for (const entry of catalog) {
const k = catalogEntryToApiKey(entry);
if (!denyKeys.has(k)) {
allowUpserts.push(entry);
}
for (let i = 0; i < allowUpserts.length; i += CHUNK) {
const slice = allowUpserts.slice(i, i + CHUNK);
await Promise.all(
slice.map((entry) => {
const plat: LaboratoryWorkflowAccessPlatform = entry.platform === 'HealthOmics' ? 'HEALTH_OMICS' : 'SEQERA';
return accessService.upsert({
LaboratoryId: laboratoryId,
WorkflowKey: `${plat}#${entry.workflowId}`,
OrganizationId: organizationId,
WorkflowName: entry.name,
Effect: 'ALLOW',
});
}),
);
if (i + CHUNK < allowUpserts.length) {
await sleep(0);
}
}

for (let i = 0; i < allowUpserts.length; i += CHUNK) {
const slice = allowUpserts.slice(i, i + CHUNK);
await Promise.all(
slice.map((entry) => {
const plat: LaboratoryWorkflowAccessPlatform = entry.platform === 'HealthOmics' ? 'HEALTH_OMICS' : 'SEQERA';
return accessService.upsert({
LaboratoryId: laboratoryId,
WorkflowKey: `${plat}#${entry.workflowId}`,
OrganizationId: organizationId,
WorkflowName: entry.name,
Effect: 'ALLOW',
});
}),
);
if (i + CHUNK < allowUpserts.length) {
await sleep(0);
}
for (const row of rows) {
if (!rowIsDeny(row)) {
continue;
}

for (const row of rows) {
if (!rowIsDeny(row)) {
continue;
}
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
await accessService.remove(laboratoryId, parsed.platform, parsed.workflowId);
const parsed = parseWorkflowAccessSortKey(row.WorkflowKey);
if (!parsed) {
continue;
}
await accessService.remove(laboratoryId, parsed.platform, parsed.workflowId);
}
}
Loading
Loading