diff --git a/.claude/settings.json b/.claude/settings.json index 8c97fa017d..fe2bfe99db 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/api|apps/app)/'; then echo 'TypeScript file modified — remember to run typecheck before committing'; fi" + "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/|packages/)'; then echo 'TypeScript file modified — run the cleanup skill and typecheck before committing'; fi" } ] }, diff --git a/.claude/skills/cleanup/SKILL.md b/.claude/skills/cleanup/SKILL.md new file mode 100644 index 0000000000..a7ced440e1 --- /dev/null +++ b/.claude/skills/cleanup/SKILL.md @@ -0,0 +1,102 @@ +--- +name: cleanup +description: "MUST run after writing or modifying code — reviews changed files for verbose patterns, inconsistencies, and readability issues before considering work done" +--- + +# Post-Implementation Cleanup + +**This skill is mandatory.** After writing or modifying code, you MUST review all changed files before reporting the task as complete. Code must be readable at a glance. + +## When to Run + +- After completing any implementation work +- After fixing bugs +- After refactoring +- Before committing + +## Checklist + +For every file you changed, verify: + +### 1. No Verbose Defensive Checks + +Extract repeated patterns into typed helpers. + +```tsx +// ❌ Verbose and repeated +const perms = typeof role.permissions === 'string' + ? JSON.parse(role.permissions) : role.permissions; +if (perms && typeof perms === 'object' && Array.isArray(perms.portal) && perms.portal.length > 0) { + +// ✅ Typed helper +const perms = parseRolePermissions(role.permissions); +if (perms?.portal?.length) { +``` + +### 2. Consistent Idioms Across Files + +The same check must use the same pattern everywhere. + +```tsx +// ❌ Inconsistent +file1: perms?.portal?.length > 0 +file2: perms?.portal?.length + +// ✅ Pick one +perms?.portal?.length +``` + +### 3. No Redundant Type Casts + +If you need a cast to satisfy TypeScript, extract a helper function instead. + +```tsx +// ❌ Verbose cast repeated in every file +const restrictedRoles: readonly string[] = RESTRICTED_ROLES; +restrictedRoles.includes(role); + +// ✅ Helper in shared package +export function isRestrictedRole(role: string): boolean { + return (RESTRICTED_ROLES as readonly string[]).includes(role); +} +``` + +### 4. Error Handling on Boundaries + +`JSON.parse`, external API calls, and DB queries at system boundaries need error handling. + +```tsx +// ❌ Unguarded parse +const parsed = JSON.parse(value); + +// ✅ Safe parse +try { + return JSON.parse(value); +} catch { + return null; +} +``` + +### 5. Shared Patterns Belong in Shared Packages + +If the same logic appears in 2+ apps (api, app, portal), extract it to a shared package (`packages/auth`, `packages/db`, etc.). + +### 6. No Dead Code + +- Remove unused imports +- Remove unused variables +- Remove unused function parameters +- Remove props that are always null/false + +### 7. Readable at a Glance + +- Function and variable names should convey intent without reading the implementation +- One-liner expressions over multi-line when equally clear +- No nested ternaries + +## How to Run + +1. List all files you modified: `git diff --name-only` +2. Read each file and check against this checklist +3. Fix any issues found +4. Typecheck after fixes: `npx tsc --noEmit` diff --git a/apps/api/src/controls/controls.controller.ts b/apps/api/src/controls/controls.controller.ts index 1b98543931..039a397d0b 100644 --- a/apps/api/src/controls/controls.controller.ts +++ b/apps/api/src/controls/controls.controller.ts @@ -84,8 +84,9 @@ export class ControlsController { async findOne( @OrganizationId() organizationId: string, @Param('id') id: string, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { - return this.controlsService.findOne(id, organizationId); + return this.controlsService.findOne(id, organizationId, frameworkInstanceId); } @Post() @@ -105,11 +106,13 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkPoliciesDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.linkPolicies( id, organizationId, dto.policyIds, + frameworkInstanceId, ); } @@ -120,8 +123,14 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkTasksDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { - return this.controlsService.linkTasks(id, organizationId, dto.taskIds); + return this.controlsService.linkTasks( + id, + organizationId, + dto.taskIds, + frameworkInstanceId, + ); } @Post(':id/requirements/link') @@ -146,11 +155,13 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkDocumentTypesDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.linkDocumentTypes( id, organizationId, dto.formTypes, + frameworkInstanceId, ); } @@ -162,11 +173,13 @@ export class ControlsController { @Param('id') id: string, @Param('formType', new ParseEnumPipe(EvidenceFormType)) formType: EvidenceFormType, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.unlinkDocumentType( id, organizationId, formType, + frameworkInstanceId, ); } diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index c4c30ba173..9476b28ba6 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -93,7 +93,15 @@ export class ControlsService { }; } - async findOne(controlId: string, organizationId: string) { + async findOne( + controlId: string, + organizationId: string, + frameworkInstanceId?: string, + ) { + if (frameworkInstanceId) { + return this.findOneForFramework(controlId, organizationId, frameworkInstanceId); + } + const control = await db.control.findUnique({ where: { id: controlId, organizationId }, include: { @@ -117,7 +125,11 @@ export class ControlsService { throw new NotFoundException('Control not found'); } - const formTypes = (control.controlDocumentTypes ?? []).map( + const policies = control.policies || []; + const tasks = control.tasks || []; + const controlDocumentTypes = control.controlDocumentTypes || []; + + const formTypes = controlDocumentTypes.map( (d) => d.formType, ); const notRelevantSettings = @@ -150,8 +162,6 @@ export class ControlsService { } // Compute progress - const policies = control.policies || []; - const tasks = control.tasks || []; const totalItems = policies.length + tasks.length; let policyCompleted = 0; @@ -168,7 +178,9 @@ export class ControlsService { return { ...control, - controlDocumentTypes: (control.controlDocumentTypes ?? []).map( + policies, + tasks, + controlDocumentTypes: controlDocumentTypes.map( (documentType) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -188,6 +200,118 @@ export class ControlsService { }; } + private async findOneForFramework( + controlId: string, + organizationId: string, + frameworkInstanceId: string, + ) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const control = await db.control.findUnique({ + where: { id: controlId, organizationId }, + include: { + frameworkPolicyLinks: { + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, + include: { policy: true }, + }, + frameworkTaskLinks: { + where: { + frameworkInstanceId, + task: { archivedAt: null }, + }, + include: { task: true }, + }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, + requirementsMapped: { + where: { archivedAt: null }, + include: { + frameworkInstance: { + include: { framework: true, customFramework: true }, + }, + requirement: true, + customRequirement: true, + }, + }, + }, + }); + + if (!control) { + throw new NotFoundException('Control not found'); + } + + const policies = control.frameworkPolicyLinks.map((link) => link.policy); + const tasks = control.frameworkTaskLinks.map((link) => link.task); + const controlDocumentTypes = control.frameworkDocumentLinks; + const formTypes = controlDocumentTypes.map((d) => d.formType); + const notRelevantSettings = + formTypes.length > 0 + ? await db.evidenceFormSetting.findMany({ + where: { + organizationId, + formType: { in: formTypes }, + isNotRelevant: true, + }, + select: { formType: true }, + }) + : []; + const notRelevantFormTypes = new Set( + notRelevantSettings.map((setting) => setting.formType), + ); + const submissionCountsByFormType: Record = {}; + if (formTypes.length > 0) { + const grouped = await db.evidenceSubmission.groupBy({ + by: ['formType'], + where: { + organizationId, + formType: { in: formTypes }, + }, + _count: { _all: true }, + }); + for (const g of grouped) { + submissionCountsByFormType[g.formType] = g._count._all; + } + } + + const policyCompleted = policies.filter((p) => p.status === 'published').length; + const taskCompleted = tasks.filter( + (t) => t.status === 'done' || t.status === 'not_relevant', + ).length; + const completed = policyCompleted + taskCompleted; + const totalItems = policies.length + tasks.length; + + const { + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...controlData + } = control; + + return { + ...controlData, + policies, + tasks, + controlDocumentTypes: controlDocumentTypes.map((documentType) => ({ + ...documentType, + isNotRelevant: notRelevantFormTypes.has(documentType.formType), + })), + submissionCountsByFormType, + progress: { + total: totalItems, + completed, + progress: + totalItems > 0 ? Math.round((completed / totalItems) * 100) : 0, + byType: { + policy: { total: policies.length, completed: policyCompleted }, + task: { total: tasks.length, completed: taskCompleted }, + }, + }, + }; + } + async getOptions(organizationId: string) { const [policies, tasks, frameworkInstances] = await Promise.all([ db.policy.findMany({ @@ -480,10 +604,25 @@ export class ControlsService { return control; } + private async ensureFrameworkInstance( + frameworkInstanceId: string, + organizationId: string, + ) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { id: true }, + }); + if (!frameworkInstance) { + throw new NotFoundException('Framework instance not found'); + } + return frameworkInstance; + } + async linkPolicies( controlId: string, organizationId: string, policyIds: string[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); @@ -495,10 +634,22 @@ export class ControlsService { throw new BadRequestException('No valid policies to link'); } - await db.control.update({ - where: { id: controlId }, - data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, - }); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlPolicyLink.createMany({ + data: policies.map((policy) => ({ + frameworkInstanceId, + controlId, + policyId: policy.id, + })), + skipDuplicates: true, + }); + } else { + await db.control.update({ + where: { id: controlId }, + data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, + }); + } return { count: policies.length }; } @@ -507,6 +658,7 @@ export class ControlsService { controlId: string, organizationId: string, taskIds: string[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); @@ -518,10 +670,22 @@ export class ControlsService { throw new BadRequestException('No valid tasks to link'); } - await db.control.update({ - where: { id: controlId }, - data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, - }); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlTaskLink.createMany({ + data: tasks.map((task) => ({ + frameworkInstanceId, + controlId, + taskId: task.id, + })), + skipDuplicates: true, + }); + } else { + await db.control.update({ + where: { id: controlId }, + data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, + }); + } return { count: tasks.length }; } @@ -627,8 +791,21 @@ export class ControlsService { controlId: string, organizationId: string, formTypes: EvidenceFormType[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const result = await db.frameworkControlDocumentTypeLink.createMany({ + data: formTypes.map((formType) => ({ + frameworkInstanceId, + controlId, + formType, + })), + skipDuplicates: true, + }); + return { count: result.count }; + } const result = await db.controlDocumentType.createMany({ data: formTypes.map((formType) => ({ controlId, formType })), skipDuplicates: true, @@ -640,8 +817,16 @@ export class ControlsService { controlId: string, organizationId: string, formType: EvidenceFormType, + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlDocumentTypeLink.deleteMany({ + where: { frameworkInstanceId, controlId, formType }, + }); + return { success: true }; + } await db.controlDocumentType.deleteMany({ where: { controlId, formType }, }); diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts index 77fd7816e2..bd4c5b2b6b 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts @@ -29,13 +29,13 @@ describe('buildManifestForFramework', () => { name: 'Logical Access Controls', description: 'desc', requirements: [{ id: 'frk_rq_cc61' }], - policyTemplates: [ - { id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' }, + frameworkPolicyLinks: [ + { policyTemplate: { id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' } }, ], - taskTemplates: [ - { id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' }, + frameworkTaskLinks: [ + { taskTemplate: { id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' } }, ], - documentTypes: ['rbac_matrix'], + frameworkDocumentLinks: [{ formType: 'rbac_matrix' }], }, ], }, @@ -70,9 +70,9 @@ describe('buildManifestForFramework', () => { { id: 'ct_shared', name: 'Shared', description: 'd', requirements: [{ id: 'rq_a' }, { id: 'rq_b' }], - policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }], - taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }], - documentTypes: [], + frameworkPolicyLinks: [{ policyTemplate: { id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null } }], + frameworkTaskLinks: [{ taskTemplate: { id: 'tt_shared', name: 'T', description: '', frequency: null, department: null } }], + frameworkDocumentLinks: [], }, ], }, @@ -82,9 +82,9 @@ describe('buildManifestForFramework', () => { { id: 'ct_shared', name: 'Shared', description: 'd', requirements: [{ id: 'rq_a' }, { id: 'rq_b' }], - policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }], - taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }], - documentTypes: [], + frameworkPolicyLinks: [{ policyTemplate: { id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null } }], + frameworkTaskLinks: [{ taskTemplate: { id: 'tt_shared', name: 'T', description: '', frequency: null, department: null } }], + frameworkDocumentLinks: [], }, ], }, @@ -103,4 +103,43 @@ describe('buildManifestForFramework', () => { (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue(null); await expect(buildManifestForFramework('missing')).rejects.toThrow('Framework not found'); }); + + it('only includes policy task and document links scoped to the requested framework', async () => { + (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_hipaa', + name: 'HIPAA', + version: '2026', + description: null, + requirements: [ + { + id: 'rq_hipaa', + identifier: 'H1', + name: 'HIPAA requirement', + description: null, + controlTemplates: [ + { + id: 'ct_shared', + name: 'Shared', + description: 'd', + requirements: [{ id: 'rq_hipaa' }, { id: 'rq_pci' }], + frameworkPolicyLinks: [ + { policyTemplate: { id: 'pt_hipaa', name: 'HIPAA Policy', description: null, content: [], frequency: null, department: null } }, + ], + frameworkTaskLinks: [ + { taskTemplate: { id: 'tt_hipaa', name: 'HIPAA Task', description: '', frequency: null, department: null } }, + ], + frameworkDocumentLinks: [{ formType: 'access_request' }], + }, + ], + }, + ], + }); + + const manifest = await buildManifestForFramework('frk_hipaa'); + + expect(manifest.controls[0].policyIds).toEqual(['pt_hipaa']); + expect(manifest.controls[0].taskIds).toEqual(['tt_hipaa']); + expect(manifest.controls[0].documentTypes).toEqual(['access_request']); + expect(manifest.controls[0].requirementIds).toEqual(['rq_hipaa']); + }); }); diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts index f6c8c13b3b..094d47d90c 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts @@ -16,8 +16,18 @@ export async function buildManifestForFramework(frameworkId: string): Promise link.policyTemplate, + ); + const taskTemplates = ct.frameworkTaskLinks.map( + (link) => link.taskTemplate, + ); if (!controlsMap.has(ct.id)) { controlsMap.set(ct.id, { id: ct.id, @@ -47,12 +63,12 @@ export async function buildManifestForFramework(frameworkId: string): Promise r.id) .filter((id) => ownRequirementIds.has(id)), - policyIds: ct.policyTemplates.map((p) => p.id), - taskIds: ct.taskTemplates.map((t) => t.id), - documentTypes: [...ct.documentTypes], + policyIds: policyTemplates.map((p) => p.id), + taskIds: taskTemplates.map((t) => t.id), + documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), }); } - for (const pt of ct.policyTemplates) { + for (const pt of policyTemplates) { if (!policiesMap.has(pt.id)) { policiesMap.set(pt.id, { id: pt.id, @@ -64,7 +80,7 @@ export async function buildManifestForFramework(frameworkId: string): Promise ({ - db: { +jest.mock('@db', () => { + const dbMock = { frameworkEditorControlTemplate: { create: jest.fn(), findUnique: jest.fn(), @@ -10,9 +10,33 @@ jest.mock('@db', () => ({ frameworkEditorRequirement: { findMany: jest.fn(), }, - }, - Prisma: { PrismaClientKnownRequestError: class {} }, -})); + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + frameworkEditorControlDocumentTypeLink: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + frameworkEditorControlPolicyTemplateLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlTaskTemplateLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn((operations) => + typeof operations === 'function' + ? operations(dbMock) + : Promise.all(operations), + ), + }; + + return { + db: dbMock, + Prisma: { PrismaClientKnownRequestError: class {} }, + }; +}); import { db } from '@db'; import { ControlTemplateService } from './control-template.service'; @@ -29,6 +53,19 @@ describe('ControlTemplateService', () => { id: 'frk_ct_new', name: 'New Control', }); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_soc2', + }); + (mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_ct_new', + name: 'New Control', + }); + (mockDb.frameworkEditorControlDocumentTypeLink.createMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (mockDb.frameworkEditorControlDocumentTypeLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 0, + }); }); describe('create', () => { @@ -66,12 +103,23 @@ describe('ControlTemplateService', () => { }); }); - it('persists documentTypes when provided', async () => { - await service.create({ ...baseDto, documentTypes: ['penetration-test'] }); + it('persists documentTypes as framework-scoped links when provided', async () => { + await service.create({ + ...baseDto, + frameworkId: 'frk_soc2', + documentTypes: ['penetration-test'], + }); - const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock - .calls[0][0]; - expect(createArgs.data.documentTypes).toEqual(['penetration-test']); + expect(mockDb.frameworkEditorControlDocumentTypeLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + formType: 'penetration-test', + }, + ], + skipDuplicates: true, + }); }); it('omits documentTypes when not provided', async () => { @@ -81,5 +129,43 @@ describe('ControlTemplateService', () => { .calls[0][0]; expect(createArgs.data).not.toHaveProperty('documentTypes'); }); + + it('requires frameworkId when documentTypes are provided', async () => { + await expect( + service.create({ ...baseDto, documentTypes: ['penetration-test'] }), + ).rejects.toThrow('frameworkId is required'); + }); + }); + + describe('scoped links', () => { + it('links policy templates with framework context', async () => { + await service.linkPolicyTemplate('frk_ct_new', 'frk_pt_1', 'frk_soc2'); + + expect(mockDb.frameworkEditorControlPolicyTemplateLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + policyTemplateId: 'frk_pt_1', + }, + ], + skipDuplicates: true, + }); + }); + + it('links task templates with framework context', async () => { + await service.linkTaskTemplate('frk_ct_new', 'frk_tt_1', 'frk_soc2'); + + expect(mockDb.frameworkEditorControlTaskTemplateLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + taskTemplateId: 'frk_tt_1', + }, + ], + skipDuplicates: true, + }); + }); }); }); diff --git a/apps/api/src/framework-editor/control-template/control-template.service.ts b/apps/api/src/framework-editor/control-template/control-template.service.ts index 565fc4a151..44be100d1e 100644 --- a/apps/api/src/framework-editor/control-template/control-template.service.ts +++ b/apps/api/src/framework-editor/control-template/control-template.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, NotFoundException, ConflictException, @@ -14,13 +15,56 @@ export class ControlTemplateService { private readonly logger = new Logger(ControlTemplateService.name); async findAll(take = 500, skip = 0, frameworkId?: string) { - return db.frameworkEditorControlTemplate.findMany({ + if (frameworkId) { + const controls = await db.frameworkEditorControlTemplate.findMany({ + take, + skip, + orderBy: { createdAt: 'asc' }, + where: { requirements: { some: { frameworkId } } }, + include: { + requirements: { + select: { + id: true, + name: true, + framework: { select: { name: true } }, + }, + }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplate: { select: { id: true, name: true } } }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplate: { select: { id: true, name: true } } }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, + }, + }); + + return controls.map( + ({ + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...control + }) => ({ + ...control, + policyTemplates: frameworkPolicyLinks.map( + (link) => link.policyTemplate, + ), + taskTemplates: frameworkTaskLinks.map((link) => link.taskTemplate), + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + }), + ); + } + + const controls = await db.frameworkEditorControlTemplate.findMany({ take, skip, orderBy: { createdAt: 'asc' }, - where: frameworkId - ? { requirements: { some: { frameworkId } } } - : undefined, include: { policyTemplates: { select: { id: true, name: true } }, requirements: { @@ -33,6 +77,7 @@ export class ControlTemplateService { taskTemplates: { select: { id: true, name: true } }, }, }); + return controls; } async findById(id: string) { @@ -55,30 +100,86 @@ export class ControlTemplateService { } async create(dto: CreateControlTemplateDto) { - const ct = await db.frameworkEditorControlTemplate.create({ - data: { - name: dto.name, - description: dto.description ?? '', - ...(dto.documentTypes && { - documentTypes: dto.documentTypes as EvidenceFormType[], - }), - }, + if (dto.documentTypes === undefined) { + const ct = await db.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + }, + }); + this.logger.log(`Created control template: ${ct.name} (${ct.id})`); + return ct; + } + + const scopedFrameworkId = await this.ensureFramework(dto.frameworkId); + const uniqueFormTypes = Array.from( + new Set(dto.documentTypes as EvidenceFormType[]), + ); + const ct = await db.$transaction(async (tx) => { + const created = await tx.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: uniqueFormTypes.map((formType) => ({ + frameworkId: scopedFrameworkId, + controlTemplateId: created.id, + formType, + })), + skipDuplicates: true, + }); + return created; }); this.logger.log(`Created control template: ${ct.name} (${ct.id})`); return ct; } async update(id: string, dto: UpdateControlTemplateDto) { - await this.findById(id); - const updated = await db.frameworkEditorControlTemplate.update({ - where: { id }, - data: { - ...(dto.name !== undefined && { name: dto.name }), - ...(dto.description !== undefined && { description: dto.description }), - ...(dto.documentTypes !== undefined && { - documentTypes: dto.documentTypes as EvidenceFormType[], - }), - }, + if (dto.documentTypes === undefined) { + await this.findById(id); + const updated = await db.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + }, + }); + this.logger.log(`Updated control template: ${updated.name} (${id})`); + return updated; + } + + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId: id, + frameworkId: dto.frameworkId, + }); + const uniqueFormTypes = Array.from( + new Set(dto.documentTypes as EvidenceFormType[]), + ); + const updated = await db.$transaction(async (tx) => { + const control = await tx.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: id, + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: uniqueFormTypes.map((formType) => ({ + frameworkId: scopedFrameworkId, + controlTemplateId: id, + formType, + })), + skipDuplicates: true, + }); + return control; }); this.logger.log(`Updated control template: ${updated.name} (${id})`); return updated; @@ -119,35 +220,147 @@ export class ControlTemplateService { return { message: 'Requirement unlinked' }; } - async linkPolicyTemplate(controlId: string, policyTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { policyTemplates: { connect: { id: policyTemplateId } } }, + async linkPolicyTemplate( + controlId: string, + policyTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlPolicyTemplateLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + policyTemplateId, + }], + skipDuplicates: true, }); return { message: 'Policy template linked' }; } - async unlinkPolicyTemplate(controlId: string, policyTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { policyTemplates: { disconnect: { id: policyTemplateId } } }, + async unlinkPolicyTemplate( + controlId: string, + policyTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlPolicyTemplateLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + policyTemplateId, + }, }); return { message: 'Policy template unlinked' }; } - async linkTaskTemplate(controlId: string, taskTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { taskTemplates: { connect: { id: taskTemplateId } } }, + async linkTaskTemplate( + controlId: string, + taskTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + taskTemplateId, + }], + skipDuplicates: true, }); return { message: 'Task template linked' }; } - async unlinkTaskTemplate(controlId: string, taskTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { taskTemplates: { disconnect: { id: taskTemplateId } } }, + async unlinkTaskTemplate( + controlId: string, + taskTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlTaskTemplateLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + taskTemplateId, + }, }); return { message: 'Task template unlinked' }; } + + async linkDocumentType( + controlId: string, + formType: EvidenceFormType, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlDocumentTypeLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + formType, + }], + skipDuplicates: true, + }); + return { message: 'Document type linked' }; + } + + async unlinkDocumentType( + controlId: string, + formType: EvidenceFormType, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlDocumentTypeLink.deleteMany({ + where: { frameworkId: scopedFrameworkId, controlTemplateId: controlId, formType }, + }); + return { message: 'Document type unlinked' }; + } + + private async ensureFrameworkScopedControl(params: { + controlId: string; + frameworkId?: string; + }): Promise { + const frameworkId = await this.ensureFramework(params.frameworkId); + const control = await db.frameworkEditorControlTemplate.findUnique({ + where: { id: params.controlId }, + select: { id: true }, + }); + if (!control) { + throw new NotFoundException( + `Control template ${params.controlId} not found`, + ); + } + return frameworkId; + } + + private async ensureFramework(frameworkId?: string): Promise { + if (!frameworkId) { + throw new BadRequestException( + 'frameworkId is required for policy, task, and document links', + ); + } + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); + if (!framework) throw new NotFoundException('Framework not found'); + return frameworkId; + } } diff --git a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts index d83d20c310..58b523b980 100644 --- a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts +++ b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts @@ -24,4 +24,9 @@ export class CreateControlTemplateDto { @IsString({ each: true }) @IsOptional() documentTypes?: string[]; + + @ApiPropertyOptional({ example: 'frk_soc2' }) + @IsString() + @IsOptional() + frameworkId?: string; } diff --git a/apps/api/src/framework-editor/framework/framework-export.service.ts b/apps/api/src/framework-editor/framework/framework-export.service.ts index 6637eb4c74..cbb1627c49 100644 --- a/apps/api/src/framework-editor/framework/framework-export.service.ts +++ b/apps/api/src/framework-editor/framework/framework-export.service.ts @@ -67,17 +67,31 @@ export class FrameworkExportService { where: { requirements: { some: { frameworkId } } }, include: { requirements: { select: { id: true }, where: { frameworkId } }, - policyTemplates: { select: { id: true } }, - taskTemplates: { select: { id: true } }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplateId: true }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplateId: true }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, }, orderBy: { name: 'asc' }, }); const policyIds = new Set( - controlTemplates.flatMap((ct) => ct.policyTemplates.map((p) => p.id)), + controlTemplates.flatMap((ct) => + ct.frameworkPolicyLinks.map((link) => link.policyTemplateId), + ), ); const taskIds = new Set( - controlTemplates.flatMap((ct) => ct.taskTemplates.map((t) => t.id)), + controlTemplates.flatMap((ct) => + ct.frameworkTaskLinks.map((link) => link.taskTemplateId), + ), ); const policyTemplates = await db.frameworkEditorPolicyTemplate.findMany({ @@ -117,15 +131,15 @@ export class FrameworkExportService { controlTemplates: controlTemplates.map((ct) => ({ name: ct.name, description: ct.description, - documentTypes: ct.documentTypes as string[], + documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), requirementIndices: ct.requirements .map((r) => reqIdToIndex.get(r.id)) .filter((i): i is number => i !== undefined), - policyTemplateIndices: ct.policyTemplates - .map((p) => policyIdToIndex.get(p.id)) + policyTemplateIndices: ct.frameworkPolicyLinks + .map((link) => policyIdToIndex.get(link.policyTemplateId)) .filter((i): i is number => i !== undefined), - taskTemplateIndices: ct.taskTemplates - .map((t) => taskIdToIndex.get(t.id)) + taskTemplateIndices: ct.frameworkTaskLinks + .map((link) => taskIdToIndex.get(link.taskTemplateId)) .filter((i): i is number => i !== undefined), })), policyTemplates: policyTemplates.map((p) => ({ @@ -205,33 +219,63 @@ export class FrameworkExportService { ), ); - await Promise.all( + const createdControls = await Promise.all( (dto.controlTemplates ?? []).map((ct) => tx.frameworkEditorControlTemplate.create({ data: { name: ct.name, description: ct.description, - documentTypes: ct.documentTypes ?? [], requirements: { connect: (ct.requirementIndices ?? []).map((i) => ({ id: createdRequirements[i].id, })), }, - policyTemplates: { - connect: (ct.policyTemplateIndices ?? []).map((i) => ({ - id: createdPolicies[i].id, - })), - }, - taskTemplates: { - connect: (ct.taskTemplateIndices ?? []).map((i) => ({ - id: createdTasks[i].id, - })), - }, }, }), ), ); + const policyLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.policyTemplateIndices ?? []).map((policyIndex) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + policyTemplateId: createdPolicies[policyIndex].id, + })), + ); + const taskLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.taskTemplateIndices ?? []).map((taskIndex) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + taskTemplateId: createdTasks[taskIndex].id, + })), + ); + const documentLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.documentTypes ?? []).map((formType) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + formType: formType as EvidenceFormType, + })), + ); + + if (policyLinks.length > 0) { + await tx.frameworkEditorControlPolicyTemplateLink.createMany({ + data: policyLinks, + skipDuplicates: true, + }); + } + if (taskLinks.length > 0) { + await tx.frameworkEditorControlTaskTemplateLink.createMany({ + data: taskLinks, + skipDuplicates: true, + }); + } + if (documentLinks.length > 0) { + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: documentLinks, + skipDuplicates: true, + }); + } + this.logger.log( `Imported framework "${framework.name}" (${framework.id}): ` + `${createdRequirements.length} requirements, ` + diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts index 1b9f5c5327..26bc26a267 100644 --- a/apps/api/src/framework-editor/framework/framework.service.ts +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -127,10 +127,9 @@ export class FrameworkEditorFrameworkService { async getControls(frameworkId: string) { await this.findById(frameworkId); - return db.frameworkEditorControlTemplate.findMany({ + const controls = await db.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { frameworkId } } }, include: { - policyTemplates: { select: { id: true, name: true } }, requirements: { select: { id: true, @@ -138,10 +137,37 @@ export class FrameworkEditorFrameworkService { framework: { select: { name: true } }, }, }, - taskTemplates: { select: { id: true, name: true } }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplate: { select: { id: true, name: true } } }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplate: { select: { id: true, name: true } } }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, }, orderBy: { createdAt: 'asc' }, }); + + return controls.map( + ({ + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...control + }) => ({ + ...control, + policyTemplates: frameworkPolicyLinks.map( + (link) => link.policyTemplate, + ), + taskTemplates: frameworkTaskLinks.map((link) => link.taskTemplate), + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + }), + ); } async getPolicies(frameworkId: string) { @@ -149,8 +175,8 @@ export class FrameworkEditorFrameworkService { return db.frameworkEditorPolicyTemplate.findMany({ where: { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, + frameworkControlLinks: { + some: { frameworkId }, }, }, orderBy: { name: 'asc' }, @@ -162,25 +188,47 @@ export class FrameworkEditorFrameworkService { return db.frameworkEditorTaskTemplate.findMany({ where: { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, + frameworkControlLinks: { + some: { frameworkId }, }, }, include: { - controlTemplates: { select: { id: true, name: true } }, + frameworkControlLinks: { + where: { frameworkId }, + select: { controlTemplate: { select: { id: true, name: true } } }, + }, }, orderBy: { name: 'asc' }, - }); + }).then((tasks) => + tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })), + ); } async getDocuments(frameworkId: string) { await this.findById(frameworkId); - return db.frameworkEditorControlTemplate.findMany({ + const controls = await db.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { frameworkId } } }, - select: { id: true, name: true, documentTypes: true }, + select: { + id: true, + name: true, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, + }, orderBy: { name: 'asc' }, }); + + return controls.map(({ frameworkDocumentLinks, ...control }) => ({ + ...control, + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + })); } async linkControl(frameworkId: string, controlId: string) { @@ -213,7 +261,7 @@ export class FrameworkEditorFrameworkService { where: { requirements: { some: { frameworkId } } }, select: { id: true }, }) - .then((cts) => cts.map((ct) => ({ id: ct.id }))); + .then((cts) => cts.map((ct) => ct.id)); if (controlIds.length === 0) { throw new ConflictException( @@ -221,9 +269,13 @@ export class FrameworkEditorFrameworkService { ); } - await db.frameworkEditorTaskTemplate.update({ - where: { id: taskId }, - data: { controlTemplates: { connect: controlIds } }, + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: controlIds.map((controlTemplateId) => ({ + frameworkId, + controlTemplateId, + taskTemplateId: taskId, + })), + skipDuplicates: true, }); this.logger.log(`Linked task ${taskId} to framework ${frameworkId}`); @@ -238,7 +290,7 @@ export class FrameworkEditorFrameworkService { where: { requirements: { some: { frameworkId } } }, select: { id: true }, }) - .then((cts) => cts.map((ct) => ({ id: ct.id }))); + .then((cts) => cts.map((ct) => ct.id)); if (controlIds.length === 0) { throw new ConflictException( @@ -246,9 +298,13 @@ export class FrameworkEditorFrameworkService { ); } - await db.frameworkEditorPolicyTemplate.update({ - where: { id: policyId }, - data: { controlTemplates: { connect: controlIds } }, + await db.frameworkEditorControlPolicyTemplateLink.createMany({ + data: controlIds.map((controlTemplateId) => ({ + frameworkId, + controlTemplateId, + policyTemplateId: policyId, + })), + skipDuplicates: true, }); this.logger.log(`Linked policy ${policyId} to framework ${frameworkId}`); diff --git a/apps/api/src/framework-editor/policy-template/policy-template.service.ts b/apps/api/src/framework-editor/policy-template/policy-template.service.ts index d97b332eba..234a479e02 100644 --- a/apps/api/src/framework-editor/policy-template/policy-template.service.ts +++ b/apps/api/src/framework-editor/policy-template/policy-template.service.ts @@ -13,17 +13,43 @@ export class PolicyTemplateService { private readonly logger = new Logger(PolicyTemplateService.name); async findAll(take = 500, skip = 0, frameworkId?: string) { + if (frameworkId) { + const policies = await db.frameworkEditorPolicyTemplate.findMany({ + take, + skip, + orderBy: { name: 'asc' }, + where: { frameworkControlLinks: { some: { frameworkId } } }, + include: { + frameworkControlLinks: { + where: { frameworkId }, + select: { + controlTemplate: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + return policies.map(({ frameworkControlLinks, ...policy }) => ({ + ...policy, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })); + } + return db.frameworkEditorPolicyTemplate.findMany({ take, skip, orderBy: { name: 'asc' }, - where: frameworkId - ? { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, - }, - } - : undefined, include: { controlTemplates: { select: { diff --git a/apps/api/src/framework-editor/task-template/task-template.controller.ts b/apps/api/src/framework-editor/task-template/task-template.controller.ts index f0cd7a7170..8b15b71a25 100644 --- a/apps/api/src/framework-editor/task-template/task-template.controller.ts +++ b/apps/api/src/framework-editor/task-template/task-template.controller.ts @@ -111,4 +111,32 @@ export class TaskTemplateController { ) { return await this.taskTemplateService.deleteById(taskTemplateId); } + + @Post(':id/control-templates/:controlTemplateId') + @ApiOperation({ summary: 'Link a control template to a task template' }) + async linkControlTemplate( + @Param('id', ValidateIdPipe) taskTemplateId: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.taskTemplateService.linkControlTemplate( + taskTemplateId, + controlTemplateId, + frameworkId, + ); + } + + @Delete(':id/control-templates/:controlTemplateId') + @ApiOperation({ summary: 'Unlink a control template from a task template' }) + async unlinkControlTemplate( + @Param('id', ValidateIdPipe) taskTemplateId: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.taskTemplateService.unlinkControlTemplate( + taskTemplateId, + controlTemplateId, + frameworkId, + ); + } } diff --git a/apps/api/src/framework-editor/task-template/task-template.service.ts b/apps/api/src/framework-editor/task-template/task-template.service.ts index 217b865803..a43aebdafa 100644 --- a/apps/api/src/framework-editor/task-template/task-template.service.ts +++ b/apps/api/src/framework-editor/task-template/task-template.service.ts @@ -29,15 +29,43 @@ export class TaskTemplateService { async findAll(frameworkId?: string) { try { + if (frameworkId) { + const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ + orderBy: { name: 'asc' }, + where: { frameworkControlLinks: { some: { frameworkId } } }, + include: { + frameworkControlLinks: { + where: { frameworkId }, + select: { + controlTemplate: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + + this.logger.log( + `Retrieved ${taskTemplates.length} framework editor task templates`, + ); + return taskTemplates.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })); + } + const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ orderBy: { name: 'asc' }, - where: frameworkId - ? { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, - }, - } - : undefined, include: { controlTemplates: { select: { @@ -66,6 +94,52 @@ export class TaskTemplateService { } } + async linkControlTemplate( + taskTemplateId: string, + controlTemplateId: string, + frameworkId?: string, + ) { + if (!frameworkId) { + throw new NotFoundException('Framework not found'); + } + await this.findById(taskTemplateId); + const [controlTemplate, framework] = await Promise.all([ + db.frameworkEditorControlTemplate.findUnique({ + where: { id: controlTemplateId }, + select: { id: true }, + }), + db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }), + ]); + if (!controlTemplate) { + throw new NotFoundException('Control template not found'); + } + if (!framework) { + throw new NotFoundException('Framework not found'); + } + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: [{ frameworkId, controlTemplateId, taskTemplateId }], + skipDuplicates: true, + }); + return { message: 'Control linked' }; + } + + async unlinkControlTemplate( + taskTemplateId: string, + controlTemplateId: string, + frameworkId?: string, + ) { + if (!frameworkId) { + throw new NotFoundException('Framework not found'); + } + await db.frameworkEditorControlTaskTemplateLink.deleteMany({ + where: { frameworkId, controlTemplateId, taskTemplateId }, + }); + return { message: 'Control unlinked' }; + } + async findById(id: string) { try { const taskTemplate = await db.frameworkEditorTaskTemplate.findUnique({ diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts index f376d4fcfe..dc962bab18 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts @@ -238,6 +238,79 @@ async function replayUndo( await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`; } + const scopedPolicyLinks = ctx.undo.frameworkControlPolicyLinks ?? { + connected: [], + disconnected: [], + }; + const scopedTaskLinks = ctx.undo.frameworkControlTaskLinks ?? { + connected: [], + disconnected: [], + }; + const scopedDocumentLinks = ctx.undo.frameworkControlDocumentTypeLinks ?? { + connected: [], + disconnected: [], + }; + + for (const link of scopedPolicyLinks.connected) { + await tx.frameworkControlPolicyLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + policyId: link.otherId, + }, + }); + } + if (scopedPolicyLinks.disconnected.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: scopedPolicyLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + policyId: link.otherId, + })), + skipDuplicates: true, + }); + } + + for (const link of scopedTaskLinks.connected) { + await tx.frameworkControlTaskLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + taskId: link.otherId, + }, + }); + } + if (scopedTaskLinks.disconnected.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: scopedTaskLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + taskId: link.otherId, + })), + skipDuplicates: true, + }); + } + + for (const link of scopedDocumentLinks.connected) { + await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + formType: normalizeFormType(link.otherId) as never, + }, + }); + } + if (scopedDocumentLinks.disconnected.length > 0) { + await tx.frameworkControlDocumentTypeLink.createMany({ + data: scopedDocumentLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + formType: normalizeFormType(link.otherId) as never, + })), + skipDuplicates: true, + }); + } + // Revert framework instance version pointer await tx.frameworkInstance.update({ where: { id: ctx.syncOp.frameworkInstanceId }, diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts index 5b5e359e76..73ba9d8abe 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts @@ -44,6 +44,9 @@ function mockTx() { frameworkInstance: { findMany: jest.fn().mockResolvedValue([]), update: jest.fn() }, frameworkSyncOperation: { create: jest.fn().mockResolvedValue({ id: 'fso_new' }) }, controlDocumentType: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'cdt_new' }), delete: jest.fn() }, + frameworkControlPolicyLink: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + frameworkControlTaskLink: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + frameworkControlDocumentTypeLink: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'fdl_new' }), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, $executeRaw: jest.fn().mockResolvedValue(0), $queryRaw: jest.fn().mockResolvedValue([]), } as any; diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts index cf9f06b19e..1ad1ab1e12 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -57,6 +57,9 @@ export async function applySync( controlDocumentTypes: { created: [], deleted: [] }, controlPolicyLinks: { connected: [], disconnected: [] }, controlTaskLinks: { connected: [], disconnected: [] }, + frameworkControlPolicyLinks: { connected: [], disconnected: [] }, + frameworkControlTaskLinks: { connected: [], disconnected: [] }, + frameworkControlDocumentTypeLinks: { connected: [], disconnected: [] }, }; const summary: SyncSummary = { controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0, @@ -309,20 +312,47 @@ export async function applySync( ` : []; const existingCpKey = new Set(existingCp.map((r) => `${r.A}::${r.B}`)); + const existingScopedCp = await tx.frameworkControlPolicyLink.findMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: { in: ctlInstIds } }, + select: { controlId: true, policyId: true }, + }); + const existingScopedCpKey = new Set( + existingScopedCp.map((r) => `${r.controlId}::${r.policyId}`), + ); const cpAdded: Array<{ controlId: string; policyId: string }> = []; + const scopedCpAdded: Array<{ controlId: string; policyId: string }> = []; for (const c of to.controls) { const ctlInst = ctlByTemplate.get(c.id); if (!ctlInst) continue; for (const pid of c.policyIds) { const polInst = polByTemplate.get(pid); if (!polInst) continue; - if (existingCpKey.has(`${ctlInst.id}::${polInst.id}`)) continue; + const key = `${ctlInst.id}::${polInst.id}`; + if (!existingScopedCpKey.has(key)) { + scopedCpAdded.push({ controlId: ctlInst.id, policyId: polInst.id }); + undo.frameworkControlPolicyLinks?.connected.push({ + controlId: ctlInst.id, + otherId: polInst.id, + }); + existingScopedCpKey.add(key); + } + if (existingCpKey.has(key)) continue; cpAdded.push({ controlId: ctlInst.id, policyId: polInst.id }); undo.controlPolicyLinks.connected.push({ controlId: ctlInst.id, otherId: polInst.id }); - existingCpKey.add(`${ctlInst.id}::${polInst.id}`); + existingCpKey.add(key); } } + if (scopedCpAdded.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: scopedCpAdded.map(({ controlId, policyId }) => ({ + frameworkInstanceId: ctx.instance.id, + controlId, + policyId, + })), + skipDuplicates: true, + }); + } if (cpAdded.length > 0) { const rows = Prisma.join( cpAdded.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`), @@ -331,19 +361,23 @@ export async function applySync( } // Diff-based removal: only edges v1 claimed and v2 dropped. - const cpRemoved: Array<{ controlId: string; policyId: string }> = []; for (const edge of diff.controlPolicyEdges.removed) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); const polInst = polByTemplate.get(edge.policyTemplateId); if (!ctlInst || !polInst) continue; - cpRemoved.push({ controlId: ctlInst.id, policyId: polInst.id }); - undo.controlPolicyLinks.disconnected.push({ controlId: ctlInst.id, otherId: polInst.id }); - } - if (cpRemoved.length > 0) { - const pairs = Prisma.join( - cpRemoved.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`), - ); - await tx.$executeRaw`DELETE FROM "_ControlToPolicy" WHERE ("A", "B") IN (${pairs})`; + const deleted = await tx.frameworkControlPolicyLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + policyId: polInst.id, + }, + }); + if (deleted.count > 0) { + undo.frameworkControlPolicyLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: polInst.id, + }); + } } const existingCt = @@ -354,20 +388,47 @@ export async function applySync( ` : []; const existingCtKey = new Set(existingCt.map((r) => `${r.A}::${r.B}`)); + const existingScopedCt = await tx.frameworkControlTaskLink.findMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: { in: ctlInstIds } }, + select: { controlId: true, taskId: true }, + }); + const existingScopedCtKey = new Set( + existingScopedCt.map((r) => `${r.controlId}::${r.taskId}`), + ); const ctAdded: Array<{ controlId: string; taskId: string }> = []; + const scopedCtAdded: Array<{ controlId: string; taskId: string }> = []; for (const c of to.controls) { const ctlInst = ctlByTemplate.get(c.id); if (!ctlInst) continue; for (const tid of c.taskIds) { const tInst = taskByTemplate.get(tid); if (!tInst) continue; - if (existingCtKey.has(`${ctlInst.id}::${tInst.id}`)) continue; + const key = `${ctlInst.id}::${tInst.id}`; + if (!existingScopedCtKey.has(key)) { + scopedCtAdded.push({ controlId: ctlInst.id, taskId: tInst.id }); + undo.frameworkControlTaskLinks?.connected.push({ + controlId: ctlInst.id, + otherId: tInst.id, + }); + existingScopedCtKey.add(key); + } + if (existingCtKey.has(key)) continue; ctAdded.push({ controlId: ctlInst.id, taskId: tInst.id }); undo.controlTaskLinks.connected.push({ controlId: ctlInst.id, otherId: tInst.id }); - existingCtKey.add(`${ctlInst.id}::${tInst.id}`); + existingCtKey.add(key); } } + if (scopedCtAdded.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: scopedCtAdded.map(({ controlId, taskId }) => ({ + frameworkInstanceId: ctx.instance.id, + controlId, + taskId, + })), + skipDuplicates: true, + }); + } if (ctAdded.length > 0) { const rows = Prisma.join( ctAdded.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`), @@ -375,19 +436,23 @@ export async function applySync( await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`; } - const ctRemoved: Array<{ controlId: string; taskId: string }> = []; for (const edge of diff.controlTaskEdges.removed) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); const tInst = taskByTemplate.get(edge.taskTemplateId); if (!ctlInst || !tInst) continue; - ctRemoved.push({ controlId: ctlInst.id, taskId: tInst.id }); - undo.controlTaskLinks.disconnected.push({ controlId: ctlInst.id, otherId: tInst.id }); - } - if (ctRemoved.length > 0) { - const pairs = Prisma.join( - ctRemoved.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`), - ); - await tx.$executeRaw`DELETE FROM "_ControlToTask" WHERE ("A", "B") IN (${pairs})`; + const deleted = await tx.frameworkControlTaskLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + taskId: tInst.id, + }, + }); + if (deleted.count > 0) { + undo.frameworkControlTaskLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: tInst.id, + }); + } } // --- Control <-> DocumentType (explicit junction table ControlDocumentType) --- @@ -402,6 +467,29 @@ export async function applySync( if (!ctlInst) continue; for (const rawFormType of c.documentTypes ?? []) { const formType = normalizeFormType(rawFormType); + const scopedExisting = await tx.frameworkControlDocumentTypeLink.findUnique({ + where: { + frameworkInstanceId_controlId_formType: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, + }, + select: { id: true }, + }); + if (!scopedExisting) { + await tx.frameworkControlDocumentTypeLink.create({ + data: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, + }); + undo.frameworkControlDocumentTypeLinks?.connected.push({ + controlId: ctlInst.id, + otherId: formType, + }); + } const existing = await tx.controlDocumentType.findUnique({ where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, select: { id: true }, @@ -418,14 +506,20 @@ export async function applySync( const ctlInst = ctlByTemplate.get(edge.controlTemplateId); if (!ctlInst) continue; const formType = normalizeFormType(edge.formType); - const existing = await tx.controlDocumentType.findUnique({ - where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, - select: { id: true }, + const deleted = await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, }); - if (!existing) continue; - await tx.controlDocumentType.delete({ where: { id: existing.id } }); - undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType }); - summary.controlDocumentTypesArchived += 1; + if (deleted.count > 0) { + undo.frameworkControlDocumentTypeLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: formType, + }); + summary.controlDocumentTypesArchived += 1; + } } // --- Persist sync op + update currentVersionId --- diff --git a/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts index b061835c93..ff6abbb004 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts @@ -5,6 +5,10 @@ const empty: FrameworkManifest = { framework: { id: 'f', name: 'n', catalogVersion: '1', description: null }, requirements: [], controls: [], policies: [], tasks: [], }; +const labels = { + fromVersionLabel: { id: 'fvr_1', version: '1.0.0' }, + toVersionLabel: { id: 'fvr_2', version: '1.1.0' }, +}; describe('buildUpdatePreview', () => { it('classifies added control', () => { @@ -14,6 +18,7 @@ describe('buildUpdatePreview', () => { instanceControls: [], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.added).toHaveLength(1); expect(preview.controls.archived).toHaveLength(0); @@ -26,6 +31,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'C', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.archived).toHaveLength(1); }); @@ -37,6 +43,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'Old', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.updatedApplied).toHaveLength(1); expect(preview.controls.updatedPreserved).toHaveLength(0); @@ -49,6 +56,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'My edit', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.updatedPreserved).toHaveLength(1); expect(preview.controls.updatedApplied).toHaveLength(0); diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts index d417da1a17..8cd564455d 100644 --- a/apps/api/src/frameworks/framework-versioning/manifest.types.ts +++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts @@ -29,7 +29,7 @@ export interface ManifestControl { requirementIds: string[]; policyIds: string[]; taskIds: string[]; - documentTypes: string[]; // EvidenceFormType enum values + documentTypes?: string[]; // EvidenceFormType enum values } export interface ManifestPolicy { diff --git a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts index 3be87ab54c..8adac1ac8e 100644 --- a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts +++ b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts @@ -10,6 +10,11 @@ export interface UndoPayload { // disconnect between a Control and a Policy / Task instance. controlPolicyLinks: ImplicitEdgeBucket; controlTaskLinks: ImplicitEdgeBucket; + // Framework-instance scoped equivalents for reusable controls. New syncs + // write these; older sync operations may not have the buckets. + frameworkControlPolicyLinks?: ImplicitEdgeBucket; + frameworkControlTaskLinks?: ImplicitEdgeBucket; + frameworkControlDocumentTypeLinks?: ImplicitEdgeBucket; } export interface EntityUndoBucket { diff --git a/apps/api/src/frameworks/frameworks-source-loader.helper.ts b/apps/api/src/frameworks/frameworks-source-loader.helper.ts index 8d23ff8a17..fa726f786d 100644 --- a/apps/api/src/frameworks/frameworks-source-loader.helper.ts +++ b/apps/api/src/frameworks/frameworks-source-loader.helper.ts @@ -38,10 +38,12 @@ export interface LoadedFrameworkSources { automationStatus: TaskAutomationStatus; }>; groupedRelations: Array<{ + frameworkId: string; controlTemplateId: string; requirementTemplateIds: string[]; policyTemplateIds: string[]; taskTemplateIds: string[]; + documentTypes: EvidenceFormType[]; }>; latestVersionByFrameworkId: Map; frameworksWithoutVersion: string[]; @@ -87,28 +89,33 @@ export async function loadFrameworkSources({ const policiesMap = new Map(); const tasksMap = new Map(); - // groupedRelations accumulates per-control edges; when a control appears in - // multiple frameworks we union its requirement/policy/task id sets. + // groupedRelations accumulates per-framework control edges. A reusable + // control can carry different policy/task/document links in each framework. const relationsByControl = new Map< string, { + frameworkId: string; controlTemplateId: string; requirementTemplateIds: Set; policyTemplateIds: Set; taskTemplateIds: Set; + documentTypes: Set; } >(); - const getOrCreateRelation = (controlTemplateId: string) => { - let rel = relationsByControl.get(controlTemplateId); + const getOrCreateRelation = (frameworkId: string, controlTemplateId: string) => { + const key = `${frameworkId}::${controlTemplateId}`; + let rel = relationsByControl.get(key); if (!rel) { rel = { + frameworkId, controlTemplateId, requirementTemplateIds: new Set(), policyTemplateIds: new Set(), taskTemplateIds: new Set(), + documentTypes: new Set(), }; - relationsByControl.set(controlTemplateId, rel); + relationsByControl.set(key, rel); } return rel; }; @@ -129,10 +136,13 @@ export async function loadFrameworkSources({ documentTypes: (c.documentTypes ?? []) as EvidenceFormType[], }); } - const rel = getOrCreateRelation(c.id); + const rel = getOrCreateRelation(frameworkId, c.id); for (const rid of c.requirementIds) rel.requirementTemplateIds.add(rid); for (const pid of c.policyIds) rel.policyTemplateIds.add(pid); for (const tid of c.taskIds) rel.taskTemplateIds.add(tid); + for (const formType of c.documentTypes ?? []) { + rel.documentTypes.add(formType as EvidenceFormType); + } } } @@ -223,19 +233,53 @@ export async function loadFrameworkSources({ where: { id: { in: fallbackRequirementIds } }, select: { id: true }, }, - policyTemplates: { select: { id: true } }, - taskTemplates: { select: { id: true } }, + frameworkPolicyLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, policyTemplateId: true }, + }, + frameworkTaskLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, taskTemplateId: true }, + }, + frameworkDocumentLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, formType: true }, + }, }, }); for (const cr of controlRelationsLive) { - const rel = getOrCreateRelation(cr.id); - for (const r of cr.requirements) rel.requirementTemplateIds.add(r.id); - for (const p of cr.policyTemplates) rel.policyTemplateIds.add(p.id); - for (const t of cr.taskTemplates) rel.taskTemplateIds.add(t.id); + const frameworkIds = new Set( + cr.requirements + .map((r) => requirementToFrameworkId.get(r.id)) + .filter((id): id is string => Boolean(id)), + ); + for (const frameworkId of frameworkIds) { + const rel = getOrCreateRelation(frameworkId, cr.id); + for (const r of cr.requirements) { + if (requirementToFrameworkId.get(r.id) === frameworkId) { + rel.requirementTemplateIds.add(r.id); + } + } + for (const link of cr.frameworkPolicyLinks) { + if (link.frameworkId === frameworkId) { + rel.policyTemplateIds.add(link.policyTemplateId); + } + } + for (const link of cr.frameworkTaskLinks) { + if (link.frameworkId === frameworkId) { + rel.taskTemplateIds.add(link.taskTemplateId); + } + } + for (const link of cr.frameworkDocumentLinks) { + if (link.frameworkId === frameworkId) { + rel.documentTypes.add(link.formType); + } + } + } } const fallbackPolicyIds = controlRelationsLive.flatMap((cr) => - cr.policyTemplates.map((p) => p.id), + cr.frameworkPolicyLinks.map((p) => p.policyTemplateId), ); if (fallbackPolicyIds.length > 0) { const livePolicies = await tx.frameworkEditorPolicyTemplate.findMany({ @@ -256,7 +300,7 @@ export async function loadFrameworkSources({ } const fallbackTaskIds = controlRelationsLive.flatMap((cr) => - cr.taskTemplates.map((t) => t.id), + cr.frameworkTaskLinks.map((t) => t.taskTemplateId), ); if (fallbackTaskIds.length > 0) { const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({ @@ -278,10 +322,12 @@ export async function loadFrameworkSources({ } const groupedRelations = Array.from(relationsByControl.values()).map((rel) => ({ + frameworkId: rel.frameworkId, controlTemplateId: rel.controlTemplateId, requirementTemplateIds: Array.from(rel.requirementTemplateIds), policyTemplateIds: Array.from(rel.policyTemplateIds), taskTemplateIds: Array.from(rel.taskTemplateIds), + documentTypes: Array.from(rel.documentTypes), })); return { diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts index 7a62e9e73b..51b7035755 100644 --- a/apps/api/src/frameworks/frameworks-upsert.helper.ts +++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts @@ -282,28 +282,26 @@ export async function upsertOrgFrameworkStructure({ const requirementMapEntries: Prisma.RequirementMapCreateManyInput[] = []; const controlDocumentTypeEntries: Prisma.ControlDocumentTypeCreateManyInput[] = []; + const frameworkControlPolicyEntries: Prisma.FrameworkControlPolicyLinkCreateManyInput[] = []; + const frameworkControlTaskEntries: Prisma.FrameworkControlTaskLinkCreateManyInput[] = []; + const frameworkControlDocumentTypeEntries: Prisma.FrameworkControlDocumentTypeLinkCreateManyInput[] = []; const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c])); for (const relation of groupedRelations) { const controlId = controlMap.get(relation.controlTemplateId); if (!controlId) continue; + const frameworkInstanceId = editorToInstanceMap.get(relation.frameworkId); + if (!frameworkInstanceId) continue; const updateData: Prisma.ControlUpdateInput = {}; let needsUpdate = false; for (const reqTemplateId of relation.requirementTemplateIds) { - const frameworkEditorId = requirementToFrameworkId.get(reqTemplateId); - const frameworkInstanceId = frameworkEditorId - ? editorToInstanceMap.get(frameworkEditorId) - : undefined; - - if (frameworkInstanceId) { - requirementMapEntries.push({ - controlId, - requirementId: reqTemplateId, - frameworkInstanceId, - }); - } + requirementMapEntries.push({ + controlId, + requirementId: reqTemplateId, + frameworkInstanceId, + }); } const policiesToConnect = relation.policyTemplateIds @@ -315,6 +313,13 @@ export async function upsertOrgFrameworkStructure({ updateData.policies = { connect: policiesToConnect }; needsUpdate = true; } + for (const policy of policiesToConnect) { + frameworkControlPolicyEntries.push({ + frameworkInstanceId, + controlId, + policyId: policy.id, + }); + } const tasksToConnect = relation.taskTemplateIds .map((ttId) => taskMap.get(ttId)) @@ -325,6 +330,13 @@ export async function upsertOrgFrameworkStructure({ updateData.tasks = { connect: tasksToConnect }; needsUpdate = true; } + for (const task of tasksToConnect) { + frameworkControlTaskEntries.push({ + frameworkInstanceId, + controlId, + taskId: task.id, + }); + } if (needsUpdate) { await tx.control.update({ @@ -337,9 +349,16 @@ export async function upsertOrgFrameworkStructure({ // documentTypes so the new org starts with the same evidence form types // the published version specified. Skip duplicates against existing rows // via the unique constraint at create time. - const ct = controlTemplateById.get(relation.controlTemplateId); - for (const formType of ct?.documentTypes ?? []) { + const documentTypes = relation.documentTypes.length > 0 + ? relation.documentTypes + : (controlTemplateById.get(relation.controlTemplateId)?.documentTypes ?? []); + for (const formType of documentTypes) { controlDocumentTypeEntries.push({ controlId, formType }); + frameworkControlDocumentTypeEntries.push({ + frameworkInstanceId, + controlId, + formType, + }); } } @@ -357,6 +376,27 @@ export async function upsertOrgFrameworkStructure({ }); } + if (frameworkControlPolicyEntries.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: frameworkControlPolicyEntries, + skipDuplicates: true, + }); + } + + if (frameworkControlTaskEntries.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: frameworkControlTaskEntries, + skipDuplicates: true, + }); + } + + if (frameworkControlDocumentTypeEntries.length > 0) { + await tx.frameworkControlDocumentTypeLink.createMany({ + data: frameworkControlDocumentTypeEntries, + skipDuplicates: true, + }); + } + return { processedFrameworks: frameworkEditorFrameworks, controlTemplates, diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 1a3debca66..99b58d839a 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -156,11 +156,15 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + where: { policy: { archivedAt: null } }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, }, - controlDocumentTypes: true, + frameworkDocumentLinks: true, requirementsMapped: { where: { archivedAt: null } }, }, }, @@ -181,11 +185,27 @@ export class FrameworksService { const controlsMap = new Map(); for (const rm of fi.requirementsMapped || []) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { requirementsMapped: _, ...controlData } = rm.control; + const { + requirementsMapped: _, + frameworkPolicyLinks, + frameworkDocumentLinks, + ...controlData + } = rm.control; + const policyLinks = rm.control.frameworkPolicyLinks.filter( + (link: { frameworkInstanceId: string }) => + link.frameworkInstanceId === fi.id, + ); + const documentLinks = rm.control.frameworkDocumentLinks.filter( + (link: { frameworkInstanceId: string }) => + link.frameworkInstanceId === fi.id, + ); controlsMap.set(rm.control.id, { ...controlData, - policies: rm.control.policies || [], - controlDocumentTypes: (rm.control.controlDocumentTypes || []).map( + policies: policyLinks.map( + (link: { policy: { id: string; name: string; status: string } }) => + link.policy, + ), + controlDocumentTypes: documentLinks.map( (documentType: { formType: EvidenceFormType }) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -208,9 +228,16 @@ export class FrameworksService { where: { organizationId, archivedAt: null, - controls: { some: { organizationId, archivedAt: null } }, + frameworkControlLinks: { + some: { frameworkInstance: { organizationId } }, + }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstance: { organizationId } }, + include: { control: true }, + }, }, - include: { controls: { where: { archivedAt: null } } }, }), db.evidenceSubmission.findMany({ where: { organizationId }, @@ -222,7 +249,12 @@ export class FrameworksService { ...fw, complianceScore: computeFrameworkComplianceScore( fw, - tasks, + tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks + .filter((link) => link.frameworkInstanceId === fw.id) + .map((link) => link.control), + })), evidenceSubmissions, ), })); @@ -239,12 +271,21 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, }, requirementsMapped: { where: { archivedAt: null } }, - controlDocumentTypes: true, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, }, }, }, @@ -262,12 +303,18 @@ export class FrameworksService { const controlsMap = new Map(); for (const rm of fi.requirementsMapped) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { requirementsMapped: _, ...controlData } = rm.control; + const { + requirementsMapped: _, + frameworkPolicyLinks, + frameworkDocumentLinks, + ...controlData + } = rm.control; controlsMap.set(rm.control.id, { ...controlData, - policies: rm.control.policies || [], + policies: + rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [], requirementsMapped: rm.control.requirementsMapped || [], - controlDocumentTypes: (rm.control.controlDocumentTypes || []).map( + controlDocumentTypes: (rm.control.frameworkDocumentLinks || []).map( (documentType) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -297,9 +344,14 @@ export class FrameworksService { where: { organizationId, archivedAt: null, - controls: { some: { organizationId, archivedAt: null } }, + frameworkControlLinks: { some: { frameworkInstanceId } }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstanceId }, + include: { control: true }, + }, }, - include: { controls: { where: { archivedAt: null } } }, }), db.requirementMap.findMany({ where: { frameworkInstanceId, archivedAt: null }, @@ -321,7 +373,10 @@ export class FrameworksService { ...rest, controls: Array.from(controlsMap.values()), requirementDefinitions, - tasks, + tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks.map((link) => link.control), + })), requirementMaps, evidenceSubmissions, }; @@ -728,25 +783,43 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, + }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, }, - controlDocumentTypes: true, }, }, }, }), db.task.findMany({ - where: { organizationId, archivedAt: null }, - include: { controls: { where: { archivedAt: null } } }, + where: { + organizationId, + archivedAt: null, + frameworkControlLinks: { some: { frameworkInstanceId } }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstanceId }, + include: { control: true }, + }, + }, }), this.getNotRelevantFormTypes(organizationId), ]); const formTypes = new Set(); for (const rc of relatedControls) { - for (const dt of rc.control.controlDocumentTypes || []) { + for (const dt of rc.control.frameworkDocumentLinks || []) { if (notRelevantFormTypes.has(dt.formType)) continue; formTypes.add(dt.formType); } @@ -772,17 +845,28 @@ export class FrameworksService { requirement, relatedControls: relatedControls.map((relatedControl) => ({ ...relatedControl, - control: { - ...relatedControl.control, - controlDocumentTypes: relatedControl.control.controlDocumentTypes.map( - (documentType) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), - ), - }, + control: (() => { + const { + frameworkPolicyLinks, + frameworkDocumentLinks, + ...control + } = relatedControl.control; + return { + ...control, + policies: frameworkPolicyLinks.map((link) => link.policy), + controlDocumentTypes: frameworkDocumentLinks.map( + (documentType) => ({ + ...documentType, + isNotRelevant: notRelevantFormTypes.has(documentType.formType), + }), + ), + }; + })(), + })), + tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks.map((link) => link.control), })), - tasks, evidenceSubmissions, siblingRequirements, }; diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 22a9df5eac..6cd24fd5c7 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, BadRequestException, - ForbiddenException, } from '@nestjs/common'; import { db } from '@db'; import { triggerEmail } from '../email/trigger-email'; @@ -10,7 +9,10 @@ import { InviteEmail } from '../email/templates/invite-member'; import { InvitePortalEmail } from '@trycompai/email'; import { BUILT_IN_ROLE_OBLIGATIONS, - RESTRICTED_ROLES, + BUILT_IN_ROLE_PERMISSIONS, + isRestrictedRole, + parseRoleObligations, + parseRolePermissions, } from '@trycompai/auth'; import type { InviteItemDto } from './dto/invite-people.dto'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; @@ -37,38 +39,26 @@ export class PeopleInviteService { }): Promise { const { organizationId, invites, callerUserId, callerRole } = params; - const isAdmin = - callerRole.includes('admin') || callerRole.includes('owner'); - const isAuditor = callerRole.includes('auditor'); - - if (!isAdmin && !isAuditor) { - throw new ForbiddenException( - "You don't have permission to invite members.", - ); - } + const callerMemberActions = await this.resolveCallerMemberActions( + callerRole, + organizationId, + ); const results: InviteResult[] = []; for (const invite of invites) { try { - // Auditors can only invite auditors - if (isAuditor && !isAdmin) { - const onlyAuditor = - invite.roles.length === 1 && invite.roles[0] === 'auditor'; - if (!onlyAuditor) { - results.push({ - email: invite.email, - success: false, - error: "Auditors can only invite users with the 'auditor' role.", - }); - continue; - } + const roleError = this.validateAssignableRoles( + invite.roles, + callerMemberActions, + ); + if (roleError) { + results.push({ email: invite.email, success: false, error: roleError }); + continue; } const email = invite.email.toLowerCase(); - const restrictedRoles: readonly string[] = RESTRICTED_ROLES; - const isStrictlyEmployee = - invite.roles.every((role) => restrictedRoles.includes(role)); + const isStrictlyEmployee = invite.roles.every(isRestrictedRole); const hasCompliance = await this.rolesHaveComplianceObligation( invite.roles, @@ -436,13 +426,64 @@ export class PeopleInviteService { select: { obligations: true }, }); - return customRoles.some((role) => { - const obligations = - typeof role.obligations === 'string' - ? JSON.parse(role.obligations) - : role.obligations || {}; - return !!obligations.compliance; - }); + return customRoles.some((role) => + parseRoleObligations(role.obligations).compliance, + ); + } + + /** + * Write-level = all CRUD actions. Callers with Write can assign any role. + * Partial access (e.g. auditor with create+read) can only assign + * restricted roles (employee/contractor) and custom roles. + */ + private validateAssignableRoles( + targetRoles: string[], + callerMemberActions: Set, + ): string | null { + const hasWriteAccess = ['create', 'read', 'update', 'delete'].every((a) => + callerMemberActions.has(a), + ); + if (hasWriteAccess) return null; + + const disallowed = targetRoles.filter( + (r) => !isRestrictedRole(r) && Object.hasOwn(BUILT_IN_ROLE_PERMISSIONS, r), + ); + if (disallowed.length > 0) { + return `You cannot assign privileged roles: ${disallowed.join(', ')}.`; + } + return null; + } + + private async resolveCallerMemberActions( + callerRole: string, + organizationId: string, + ): Promise> { + const roles = callerRole.split(',').map((r) => r.trim()); + const actions = new Set(); + const customRoleNames: string[] = []; + + for (const role of roles) { + const builtIn = BUILT_IN_ROLE_PERMISSIONS[role]; + if (builtIn?.member) { + for (const a of builtIn.member) actions.add(a); + } + if (!builtIn) customRoleNames.push(role); + } + + if (customRoleNames.length > 0) { + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true }, + }); + for (const role of customRoles) { + const perms = parseRolePermissions(role.permissions); + if (perms?.member) { + for (const a of perms.member) actions.add(a); + } + } + } + + return actions; } private buildPortalUrl(organizationId: string): string { diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx index 1e165df59d..0126facd5f 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx @@ -27,10 +27,12 @@ interface DocumentTypeRow { export function DocumentsTable({ controlId, + frameworkInstanceId, orgId, rows, }: { controlId: string; + frameworkInstanceId: string; orgId: string; rows: DocumentTypeRow[]; }) { @@ -45,7 +47,7 @@ export function DocumentsTable({ setPending(formType); try { const response = await apiClient.delete( - `/v1/controls/${controlId}/document-types/${formType}`, + `/v1/controls/${controlId}/document-types/${formType}?frameworkInstanceId=${frameworkInstanceId}`, ); if (response.error) throw new Error(response.error); toast.success('Document unlinked'); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx index b4b584642c..cf541a5d43 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx @@ -53,12 +53,13 @@ interface Breadcrumb { interface Props { orgId: string; + frameworkInstanceId: string; control: ControlDetail; breadcrumbs: Breadcrumb[]; documentRows: DocumentRow[]; } -export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRows }: Props) { +export function FrameworkControlShell({ orgId, frameworkInstanceId, control, breadcrumbs, documentRows }: Props) { const [activeTab, setActiveTab] = useState('policies'); const linkedPolicyIds = control.policies.map((p) => p.id); @@ -67,11 +68,23 @@ export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRow const actions = activeTab === 'policies' ? ( - + ) : activeTab === 'tasks' ? ( - + ) : ( - + ); return ( @@ -99,7 +112,12 @@ export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRow - + diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx index ea853e287a..b7f1257dda 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx @@ -23,9 +23,11 @@ import { export function LinkDocumentTypeSheet({ controlId, + frameworkInstanceId, alreadyLinkedFormTypes, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedFormTypes: string[]; }) { const { hasPermission } = usePermissions(); @@ -63,7 +65,7 @@ export function LinkDocumentTypeSheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/document-types/link`, + `/v1/controls/${controlId}/document-types/link?frameworkInstanceId=${frameworkInstanceId}`, { formTypes: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx index efd395c5af..e0b2928a34 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx @@ -20,9 +20,11 @@ import { useControlOptions } from './useControlOptions'; export function LinkPolicySheet({ controlId, + frameworkInstanceId, alreadyLinkedPolicyIds, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedPolicyIds: string[]; }) { const { hasPermission } = usePermissions(); @@ -61,7 +63,7 @@ export function LinkPolicySheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/policies/link`, + `/v1/controls/${controlId}/policies/link?frameworkInstanceId=${frameworkInstanceId}`, { policyIds: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx index d496972fc4..ff096c2898 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx @@ -20,9 +20,11 @@ import { useControlOptions } from './useControlOptions'; export function LinkTaskSheet({ controlId, + frameworkInstanceId, alreadyLinkedTaskIds, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedTaskIds: string[]; }) { const { hasPermission } = usePermissions(); @@ -61,7 +63,7 @@ export function LinkTaskSheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/tasks/link`, + `/v1/controls/${controlId}/tasks/link?frameworkInstanceId=${frameworkInstanceId}`, { taskIds: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx index 8153b9fe12..71055d3526 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx @@ -36,7 +36,9 @@ export default async function FrameworkControlPage({ params }: PageProps) { const { orgId, frameworkInstanceId, controlId } = await params; const [controlRes, frameworkRes] = await Promise.all([ - serverApi.get(`/v1/controls/${controlId}`), + serverApi.get( + `/v1/controls/${controlId}?frameworkInstanceId=${frameworkInstanceId}`, + ), serverApi.get(`/v1/frameworks/${frameworkInstanceId}`), ]); @@ -78,6 +80,7 @@ export default async function FrameworkControlPage({ params }: PageProps) { return ( ); diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 0e9dd0da77..01d7c8e918 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -3,6 +3,7 @@ import { resolveUserPermissions } from '@/lib/permissions.server'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; import type { Metadata } from 'next'; +import type { Role } from '@db'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { Suspense } from 'react'; @@ -32,15 +33,23 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: where: { organizationId: orgId, userId: session.user.id }, }); const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; - const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role)); - const isAuditor = currentUserRoles.includes('auditor'); - const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); const userPermissions = await resolveUserPermissions( currentUserMember?.role ?? null, orgId, ); + const canManageMembers = hasPermission(userPermissions, 'member', 'update'); + const canInviteUsers = hasPermission(userPermissions, 'member', 'create'); + + const hasWriteMemberAccess = + canInviteUsers && + hasPermission(userPermissions, 'member', 'read') && + canManageMembers && + hasPermission(userPermissions, 'member', 'delete'); + const allowedBuiltInRoles: Role[] = hasWriteMemberAccess + ? ['admin', 'auditor', 'employee', 'contractor'] + : ['employee', 'contractor']; const canManageOrgSettings = hasPermission( userPermissions, 'organization', @@ -85,6 +94,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: showEmployeeTasks canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} + allowedBuiltInRoles={allowedBuiltInRoles} organizationId={orgId} /> ); diff --git a/apps/app/src/lib/permissions.ts b/apps/app/src/lib/permissions.ts index 60c442c1c3..761764fe41 100644 --- a/apps/app/src/lib/permissions.ts +++ b/apps/app/src/lib/permissions.ts @@ -153,17 +153,6 @@ export function getDefaultRoute(permissions: UserPermissions, orgId: string): st return null; } -/** - * Resources that imply the user should have access to the main app. - * Portal-only resources (policy, compliance) are excluded — employees/contractors - * have those but should NOT enter the app. - */ -const APP_IMPLYING_RESOURCES = new Set([ - 'organization', 'member', 'control', 'evidence', 'risk', 'vendor', - 'task', 'framework', 'audit', 'finding', 'questionnaire', 'integration', - 'apiKey', 'trust', 'pentest', -]); - /** Compliance route segments — used to determine if the Compliance rail icon should show. */ const COMPLIANCE_ROUTE_SEGMENTS = [ 'overview', 'frameworks', 'controls', 'policies', 'tasks', 'documents', 'people', @@ -181,22 +170,11 @@ export function canAccessCompliance(permissions: UserPermissions): boolean { /** * Check if user can access the main app (as opposed to portal-only). * - * Returns true if the user has explicit `app:read` (built-in roles like owner/admin/auditor), - * OR if they have any permission on a resource that implies app access (e.g. a custom role - * with only `pentest:read`). - * - * Employees and contractors are portal-only — they only have `policy:read` - * and compliance obligations, neither of which is in APP_IMPLYING_RESOURCES. + * Requires explicit `app:read` — controlled by the "App Access" toggle + * on custom roles, and included by default in owner/admin/auditor. */ export function canAccessApp(permissions: UserPermissions): boolean { - if (hasPermission(permissions, 'app', 'read')) return true; - - for (const resource of Object.keys(permissions)) { - if (APP_IMPLYING_RESOURCES.has(resource) && permissions[resource]?.length > 0) { - return true; - } - } - return false; + return hasPermission(permissions, 'app', 'read'); } /** diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index a2755fc1cf..eb73f364c7 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -64,16 +64,20 @@ async function linkControlRelation( controlId: string, relation: string, itemId: string, + frameworkId?: string, ): Promise { - await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'POST' }); + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/control-template/${controlId}/${relation}/${itemId}${query}`, { method: 'POST' }); } async function unlinkControlRelation( controlId: string, relation: string, itemId: string, + frameworkId?: string, ): Promise { - await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'DELETE' }); + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/control-template/${controlId}/${relation}/${itemId}${query}`, { method: 'DELETE' }); } interface ControlsClientPageProps { @@ -94,7 +98,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId }) => apiClient<{ id: string }>('/control-template', { method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify({ ...data, frameworkId }), }), updateControl: ( id: string, @@ -102,14 +106,14 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ) => apiClient(`/control-template/${id}`, { method: 'PATCH', - body: JSON.stringify(data), + body: JSON.stringify({ ...data, frameworkId }), }), deleteControl: (id: string) => apiClient(`/control-template/${id}`, { method: 'DELETE', }), }), - [], + [frameworkId], ); const initialGridData: ControlsPageGridData[] = useMemo( () => @@ -196,10 +200,10 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId isNewRow={createdIds.has(row.original.id)} getAllItems={fetchAllPolicyTemplates} onLink={(controlId: string, ptId: string) => - linkControlRelation(controlId, 'policy-templates', ptId) + linkControlRelation(controlId, 'policy-templates', ptId, frameworkId) } onUnlink={(controlId: string, ptId: string) => - unlinkControlRelation(controlId, 'policy-templates', ptId) + unlinkControlRelation(controlId, 'policy-templates', ptId, frameworkId) } onLocalUpdate={(newItems: RelationalItem[]) => updateRelational(row.original.id, 'policyTemplates', newItems) @@ -248,10 +252,10 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId isNewRow={createdIds.has(row.original.id)} getAllItems={fetchAllTaskTemplates} onLink={(controlId: string, ttId: string) => - linkControlRelation(controlId, 'task-templates', ttId) + linkControlRelation(controlId, 'task-templates', ttId, frameworkId) } onUnlink={(controlId: string, ttId: string) => - unlinkControlRelation(controlId, 'task-templates', ttId) + unlinkControlRelation(controlId, 'task-templates', ttId, frameworkId) } onLocalUpdate={(newItems: RelationalItem[]) => updateRelational(row.original.id, 'taskTemplates', newItems) @@ -314,7 +318,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ), }), ], - [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate], + [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], ); const [sorting, setSorting] = useState([]); diff --git a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx index 38b5209445..7caf2b0dc4 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx @@ -14,6 +14,7 @@ interface ControlTemplate { interface DocumentControlsCellProps { documentType: string; controls: { id: string; name: string }[]; + frameworkId: string; onControlLinked: (documentType: string, control: { id: string; name: string }) => void; onControlUnlinked: (documentType: string, controlId: string) => void; } @@ -21,6 +22,7 @@ interface DocumentControlsCellProps { export function DocumentControlsCell({ documentType, controls, + frameworkId, onControlLinked, onControlUnlinked, }: DocumentControlsCellProps) { @@ -49,7 +51,7 @@ export function DocumentControlsCell({ useEffect(() => { if (isSearching && allControls.length === 0) { setIsLoading(true); - apiClient('/control-template') + apiClient(`/control-template?frameworkId=${frameworkId}`) .then((data: ControlTemplate[]) => data.map((c: ControlTemplate) => ({ id: c.id, name: c.name || 'Unnamed Control' })), ) @@ -57,7 +59,7 @@ export function DocumentControlsCell({ .catch(() => toast.error('Failed to load controls')) .finally(() => setIsLoading(false)); } - }, [isSearching, allControls.length]); + }, [isSearching, allControls.length, frameworkId]); const filteredControls = useMemo(() => { const linkedIds = new Set(controls.map((c) => c.id)); @@ -74,16 +76,10 @@ export function DocumentControlsCell({ } try { - const current = await apiClient(`/control-template/${control.id}`); - const currentTypes: string[] = Array.isArray(current.documentTypes) - ? current.documentTypes - : []; - if (!currentTypes.includes(documentType)) { - await apiClient(`/control-template/${control.id}`, { - method: 'PATCH', - body: JSON.stringify({ documentTypes: [...currentTypes, documentType] }), - }); - } + await apiClient( + `/control-template/${control.id}/document-types/${documentType}?frameworkId=${frameworkId}`, + { method: 'POST' }, + ); onControlLinked(documentType, control); toast.success(`Linked to ${control.name}`); } catch { @@ -92,30 +88,24 @@ export function DocumentControlsCell({ setSearch(''); setIsSearching(false); }, - [documentType, isSOADocument, onControlLinked], + [documentType, frameworkId, isSOADocument, onControlLinked], ); const handleUnlink = useCallback( async (controlId: string) => { const control = controls.find((c) => c.id === controlId); try { - const current = await apiClient(`/control-template/${controlId}`); - const currentTypes: string[] = Array.isArray(current.documentTypes) - ? current.documentTypes - : []; - await apiClient(`/control-template/${controlId}`, { - method: 'PATCH', - body: JSON.stringify({ - documentTypes: currentTypes.filter((t) => t !== documentType), - }), - }); + await apiClient( + `/control-template/${controlId}/document-types/${documentType}?frameworkId=${frameworkId}`, + { method: 'DELETE' }, + ); onControlUnlinked(documentType, controlId); toast.success(`Unlinked from ${control?.name ?? 'control'}`); } catch { toast.error('Failed to unlink control'); } }, - [documentType, controls, onControlUnlinked], + [documentType, frameworkId, controls, onControlUnlinked], ); if (!isExpanded) { diff --git a/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx index a14d92967e..89b04c7ed8 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx @@ -30,11 +30,12 @@ interface DocumentRow { interface DocumentsClientPageProps { controls: ControlWithDocumentTypes[]; + frameworkId: string; } const columnHelper = createColumnHelper(); -export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { +export function DocumentsClientPage({ controls, frameworkId }: DocumentsClientPageProps) { const [controlsState, setControlsState] = useState(controls); const data: DocumentRow[] = useMemo(() => { @@ -104,6 +105,7 @@ export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { @@ -129,7 +131,7 @@ export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { ), }), ], - [handleControlLinked, handleControlUnlinked], + [handleControlLinked, handleControlUnlinked, frameworkId], ); const [sorting, setSorting] = useState([]); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx index 3e68af85f9..29142a1a4a 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx @@ -23,5 +23,5 @@ export default async function Page({ `/framework/${frameworkId}/documents`, ); - return ; + return ; } diff --git a/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx b/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx index 8ec3956061..f7df6fe592 100644 --- a/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx +++ b/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx @@ -103,20 +103,22 @@ export function TasksClientPage({ initialTasks, emptyMessage, frameworkId }: Tas const handleLinkControl = useCallback( async (taskId: string, controlId: string): Promise => { - await apiClient(`/task-template/${taskId}/control-templates/${controlId}`, { + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/task-template/${taskId}/control-templates/${controlId}${query}`, { method: 'POST', }); }, - [], + [frameworkId], ); const handleUnlinkControl = useCallback( async (taskId: string, controlId: string): Promise => { - await apiClient(`/task-template/${taskId}/control-templates/${controlId}`, { + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/task-template/${taskId}/control-templates/${controlId}${query}`, { method: 'DELETE', }); }, - [], + [frameworkId], ); const columns = useMemo( diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index 967d7cdd41..f3aefcada3 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -7,6 +7,7 @@ import { db } from '@db/server'; import { PageHeader, PageLayout } from '@trycompai/design-system'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { hasPortalAccess } from '@/utils/portal-access'; import { OrganizationDashboard } from './components/OrganizationDashboard'; import type { FleetPolicy, Host } from './types'; @@ -49,11 +50,18 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o redirect('/'); } - // Member check - redirect happens outside try-catch if (!member) { redirect('/'); } + const canAccessPortal = await hasPortalAccess({ + roleString: member.role, + organizationId: orgId, + }); + if (!canAccessPortal) { + redirect('/'); + } + // Fleet policies - only fetch if member has a fleet device label const fleetData = await getFleetPolicies(member); diff --git a/apps/portal/src/utils/portal-access.ts b/apps/portal/src/utils/portal-access.ts new file mode 100644 index 0000000000..76aff2d8ae --- /dev/null +++ b/apps/portal/src/utils/portal-access.ts @@ -0,0 +1,45 @@ +import { + BUILT_IN_ROLE_OBLIGATIONS, + BUILT_IN_ROLE_PERMISSIONS, + parseRoleObligations, + parseRolePermissions, +} from '@trycompai/auth'; +import { db } from '@db/server'; + +export async function hasPortalAccess({ + roleString, + organizationId, +}: { + roleString: string; + organizationId: string; +}): Promise { + const roles = roleString.split(',').map((r) => r.trim()); + const builtInNames = new Set(Object.keys(BUILT_IN_ROLE_PERMISSIONS)); + const customRoleNames: string[] = []; + + for (const role of roles) { + if (builtInNames.has(role)) { + if (BUILT_IN_ROLE_PERMISSIONS[role]?.portal?.length) return true; + if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) return true; + } else { + customRoleNames.push(role); + } + } + + if (customRoleNames.length === 0) return false; + + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true, obligations: true }, + }); + + for (const role of customRoles) { + const perms = parseRolePermissions(role.permissions); + if (perms?.portal?.length) return true; + + const obligations = parseRoleObligations(role.obligations); + if (obligations.compliance) return true; + } + + return false; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index cda7a86a63..489deab2ae 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -9,11 +9,15 @@ export { allRoles, ROLE_HIERARCHY, RESTRICTED_ROLES, + isRestrictedRole, PRIVILEGED_ROLES, BUILT_IN_ROLE_PERMISSIONS, BUILT_IN_ROLE_OBLIGATIONS, type RoleName, type RoleObligations, + type RolePermissions, + parseRolePermissions, + parseRoleObligations, } from './permissions'; export { createAuthServer, type CreateAuthServerOptions, type AuthServer } from './server'; diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts index 05205260fb..7651292538 100644 --- a/packages/auth/src/permissions.ts +++ b/packages/auth/src/permissions.ts @@ -208,9 +208,10 @@ export const ROLE_HIERARCHY = [ */ export const RESTRICTED_ROLES = ['employee', 'contractor'] as const; -/** - * Roles that have full access without assignment filtering - */ +export function isRestrictedRole(role: string): boolean { + return (RESTRICTED_ROLES as readonly string[]).includes(role); +} + export const PRIVILEGED_ROLES = ['owner', 'admin', 'auditor'] as const; /** @@ -257,3 +258,29 @@ export const BUILT_IN_ROLE_OBLIGATIONS: Record = { employee: { compliance: true }, contractor: { compliance: true }, }; + +// ─── JSON field parsers ───────────────────────────────────────────── +// OrganizationRole stores permissions/obligations as JSON text in the DB. + +export type RolePermissions = Record; + +function parseJsonField(value: unknown): T | null { + try { + if (!value) return null; + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as T; + } + return null; + } catch { + return null; + } +} + +export function parseRolePermissions(value: unknown): RolePermissions | null { + return parseJsonField(value); +} + +export function parseRoleObligations(value: unknown): RoleObligations { + return parseJsonField(value) ?? {}; +} diff --git a/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql b/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql new file mode 100644 index 0000000000..ed07a6c9ae --- /dev/null +++ b/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql @@ -0,0 +1,161 @@ +-- CreateTable +CREATE TABLE "FrameworkEditorControlPolicyTemplateLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fcp'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "policyTemplateId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkEditorControlTaskTemplateLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fct'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "taskTemplateId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlTaskTemplateLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkEditorControlDocumentTypeLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fcd'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "formType" "EvidenceFormType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlDocumentTypeLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlPolicyLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fpl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "policyId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlPolicyLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlTaskLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('ftl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlTaskLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlDocumentTypeLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fdl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "formType" "EvidenceFormType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlDocumentTypeLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlPolicyTemplateLink_controlTemplateId_idx" ON "FrameworkEditorControlPolicyTemplateLink"("controlTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlPolicyTemplateLink_policyTemplateId_idx" ON "FrameworkEditorControlPolicyTemplateLink"("policyTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlPolicyTemplateLink_frameworkId_contro_key" ON "FrameworkEditorControlPolicyTemplateLink"("frameworkId", "controlTemplateId", "policyTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlTaskTemplateLink_controlTemplateId_idx" ON "FrameworkEditorControlTaskTemplateLink"("controlTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlTaskTemplateLink_taskTemplateId_idx" ON "FrameworkEditorControlTaskTemplateLink"("taskTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlTaskTemplateLink_frameworkId_controlT_key" ON "FrameworkEditorControlTaskTemplateLink"("frameworkId", "controlTemplateId", "taskTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlDocumentTypeLink_controlTemplateId_idx" ON "FrameworkEditorControlDocumentTypeLink"("controlTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlDocumentTypeLink_frameworkId_controlT_key" ON "FrameworkEditorControlDocumentTypeLink"("frameworkId", "controlTemplateId", "formType"); + +-- CreateIndex +CREATE INDEX "FrameworkControlPolicyLink_controlId_idx" ON "FrameworkControlPolicyLink"("controlId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlPolicyLink_policyId_idx" ON "FrameworkControlPolicyLink"("policyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlPolicyLink_frameworkInstanceId_controlId_po_key" ON "FrameworkControlPolicyLink"("frameworkInstanceId", "controlId", "policyId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlTaskLink_controlId_idx" ON "FrameworkControlTaskLink"("controlId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlTaskLink_taskId_idx" ON "FrameworkControlTaskLink"("taskId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlTaskLink_frameworkInstanceId_controlId_task_key" ON "FrameworkControlTaskLink"("frameworkInstanceId", "controlId", "taskId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlDocumentTypeLink_controlId_idx" ON "FrameworkControlDocumentTypeLink"("controlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlDocumentTypeLink_frameworkInstanceId_contro_key" ON "FrameworkControlDocumentTypeLink"("frameworkInstanceId", "controlId", "formType"); + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_policyTemplateId_fkey" FOREIGN KEY ("policyTemplateId") REFERENCES "FrameworkEditorPolicyTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_taskTemplateId_fkey" FOREIGN KEY ("taskTemplateId") REFERENCES "FrameworkEditorTaskTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlDocumentTypeLink" ADD CONSTRAINT "FrameworkEditorControlDocumentTypeLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlDocumentTypeLink" ADD CONSTRAINT "FrameworkEditorControlDocumentTypeLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "Policy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlDocumentTypeLink" ADD CONSTRAINT "FrameworkControlDocumentTypeLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlDocumentTypeLink" ADD CONSTRAINT "FrameworkControlDocumentTypeLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql b/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql new file mode 100644 index 0000000000..97352498bd --- /dev/null +++ b/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql @@ -0,0 +1,284 @@ +-- Backfill framework-scoped control links from the best available source of truth. +-- +-- Published framework versions are authoritative for already-versioned +-- framework definitions and organization instances. Frameworks/instances +-- without a published version fall back to the previous global link tables so +-- local or not-yet-versioned data does not appear empty after deploy. + +-- Catalog/template links from each framework's latest published manifest. +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlPolicyTemplateLink" ( + "frameworkId", + "controlTemplateId", + "policyTemplateId" +) +SELECT + latest_versions."frameworkId", + control_template.id, + policy_template.id +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'policyIds', '[]'::jsonb)) AS policy_ids(policy_id) +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +JOIN "FrameworkEditorPolicyTemplate" policy_template ON policy_template.id = policy_ids.policy_id +ON CONFLICT ("frameworkId", "controlTemplateId", "policyTemplateId") DO NOTHING; + +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlTaskTemplateLink" ( + "frameworkId", + "controlTemplateId", + "taskTemplateId" +) +SELECT + latest_versions."frameworkId", + control_template.id, + task_template.id +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'taskIds', '[]'::jsonb)) AS task_ids(task_id) +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +JOIN "FrameworkEditorTaskTemplate" task_template ON task_template.id = task_ids.task_id +ON CONFLICT ("frameworkId", "controlTemplateId", "taskTemplateId") DO NOTHING; + +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlDocumentTypeLink" ( + "frameworkId", + "controlTemplateId", + "formType" +) +SELECT + latest_versions."frameworkId", + control_template.id, + document_type_labels.enumlabel::"EvidenceFormType" +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'documentTypes', '[]'::jsonb)) AS document_types(form_type) +JOIN pg_enum document_type_labels + ON document_type_labels.enumtypid = '"EvidenceFormType"'::regtype + AND document_type_labels.enumlabel = REPLACE(document_types.form_type, '_', '-') +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +ON CONFLICT ("frameworkId", "controlTemplateId", "formType") DO NOTHING; + +-- Catalog/template fallback for frameworks without any published versions. +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlPolicyTemplateLink" ( + "frameworkId", + "controlTemplateId", + "policyTemplateId" +) +SELECT DISTINCT + requirement."frameworkId", + control_policy."A", + control_policy."B" +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorPolicyTemplate" control_policy + ON control_policy."A" = control_requirement."A" +ON CONFLICT ("frameworkId", "controlTemplateId", "policyTemplateId") DO NOTHING; + +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlTaskTemplateLink" ( + "frameworkId", + "controlTemplateId", + "taskTemplateId" +) +SELECT DISTINCT + requirement."frameworkId", + control_task."A", + control_task."B" +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorTaskTemplate" control_task + ON control_task."A" = control_requirement."A" +ON CONFLICT ("frameworkId", "controlTemplateId", "taskTemplateId") DO NOTHING; + +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlDocumentTypeLink" ( + "frameworkId", + "controlTemplateId", + "formType" +) +SELECT DISTINCT + requirement."frameworkId", + control_template.id, + document_type.form_type +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "FrameworkEditorControlTemplate" control_template + ON control_template.id = control_requirement."A" +CROSS JOIN LATERAL unnest(control_template."documentTypes") AS document_type(form_type) +ON CONFLICT ("frameworkId", "controlTemplateId", "formType") DO NOTHING; + +-- Organization framework-instance links from each instance's pinned version. +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlPolicyLink" ( + "frameworkInstanceId", + "controlId", + "policyId" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + policy.id +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'policyIds', '[]'::jsonb)) AS policy_ids(policy_template_id) +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +JOIN "Policy" policy + ON policy."organizationId" = versioned_instances."organizationId" + AND policy."policyTemplateId" = policy_ids.policy_template_id +ON CONFLICT ("frameworkInstanceId", "controlId", "policyId") DO NOTHING; + +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlTaskLink" ( + "frameworkInstanceId", + "controlId", + "taskId" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + task.id +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'taskIds', '[]'::jsonb)) AS task_ids(task_template_id) +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +JOIN "Task" task + ON task."organizationId" = versioned_instances."organizationId" + AND task."taskTemplateId" = task_ids.task_template_id +ON CONFLICT ("frameworkInstanceId", "controlId", "taskId") DO NOTHING; + +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlDocumentTypeLink" ( + "frameworkInstanceId", + "controlId", + "formType" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + document_type_labels.enumlabel::"EvidenceFormType" +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'documentTypes', '[]'::jsonb)) AS document_types(form_type) +JOIN pg_enum document_type_labels + ON document_type_labels.enumtypid = '"EvidenceFormType"'::regtype + AND document_type_labels.enumlabel = REPLACE(document_types.form_type, '_', '-') +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +ON CONFLICT ("frameworkInstanceId", "controlId", "formType") DO NOTHING; + +-- Organization-instance fallback for instances without pinned versions. +INSERT INTO "FrameworkControlPolicyLink" ( + "frameworkInstanceId", + "controlId", + "policyId" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_policy."A", + control_policy."B" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "_ControlToPolicy" control_policy ON control_policy."A" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "policyId") DO NOTHING; + +INSERT INTO "FrameworkControlTaskLink" ( + "frameworkInstanceId", + "controlId", + "taskId" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_task."A", + control_task."B" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "_ControlToTask" control_task ON control_task."A" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "taskId") DO NOTHING; + +INSERT INTO "FrameworkControlDocumentTypeLink" ( + "frameworkInstanceId", + "controlId", + "formType" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_document_type."controlId", + control_document_type."formType" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "ControlDocumentType" control_document_type + ON control_document_type."controlId" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "formType") DO NOTHING; diff --git a/packages/db/prisma/schema/control.prisma b/packages/db/prisma/schema/control.prisma index 0a70fc5a9e..f64e69b47d 100644 --- a/packages/db/prisma/schema/control.prisma +++ b/packages/db/prisma/schema/control.prisma @@ -15,14 +15,17 @@ model Control { archivedAt DateTime? // Relationships - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - requirementsMapped RequirementMap[] - tasks Task[] - policies Policy[] - controlTemplateId String? - controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id]) - controlDocumentTypes ControlDocumentType[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + requirementsMapped RequirementMap[] + tasks Task[] + policies Policy[] + controlTemplateId String? + controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id]) + controlDocumentTypes ControlDocumentType[] + frameworkPolicyLinks FrameworkControlPolicyLink[] + frameworkTaskLinks FrameworkControlTaskLink[] + frameworkDocumentLinks FrameworkControlDocumentTypeLink[] @@index([organizationId]) @@index([organizationId, archivedAt]) diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma index 6bb6e302f5..00db60d196 100644 --- a/packages/db/prisma/schema/framework-editor.prisma +++ b/packages/db/prisma/schema/framework-editor.prisma @@ -18,12 +18,15 @@ model FrameworkEditorFramework { description String visible Boolean @default(false) - requirements FrameworkEditorRequirement[] - frameworkInstances FrameworkInstance[] - soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework - soaDocuments SOADocument[] // SOA documents from organizations - timelineTemplates TimelineTemplate[] - versions FrameworkVersion[] + requirements FrameworkEditorRequirement[] + frameworkInstances FrameworkInstance[] + soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework + soaDocuments SOADocument[] // SOA documents from organizations + timelineTemplates TimelineTemplate[] + versions FrameworkVersion[] + controlPolicyLinks FrameworkEditorControlPolicyTemplateLink[] + controlTaskLinks FrameworkEditorControlTaskTemplateLink[] + controlDocumentLinks FrameworkEditorControlDocumentTypeLink[] // Dates createdAt DateTime @default(now()) @@ -55,7 +58,8 @@ model FrameworkEditorPolicyTemplate { department Departments // Using the enum from shared.prisma content Json - controlTemplates FrameworkEditorControlTemplate[] + controlTemplates FrameworkEditorControlTemplate[] + frameworkControlLinks FrameworkEditorControlPolicyTemplateLink[] // Dates createdAt DateTime @default(now()) @@ -73,7 +77,8 @@ model FrameworkEditorTaskTemplate { department Departments // Using the enum from shared.prisma automationStatus TaskAutomationStatus @default(AUTOMATED) - controlTemplates FrameworkEditorControlTemplate[] + controlTemplates FrameworkEditorControlTemplate[] + frameworkControlLinks FrameworkEditorControlTaskTemplateLink[] // Dates createdAt DateTime @default(now()) @@ -88,10 +93,13 @@ model FrameworkEditorControlTemplate { name String description String - policyTemplates FrameworkEditorPolicyTemplate[] - requirements FrameworkEditorRequirement[] - taskTemplates FrameworkEditorTaskTemplate[] - documentTypes EvidenceFormType[] + policyTemplates FrameworkEditorPolicyTemplate[] + requirements FrameworkEditorRequirement[] + taskTemplates FrameworkEditorTaskTemplate[] + documentTypes EvidenceFormType[] + frameworkPolicyLinks FrameworkEditorControlPolicyTemplateLink[] + frameworkTaskLinks FrameworkEditorControlTaskTemplateLink[] + frameworkDocumentLinks FrameworkEditorControlDocumentTypeLink[] // Dates createdAt DateTime @default(now()) @@ -100,3 +108,49 @@ model FrameworkEditorControlTemplate { // Instances controls Control[] } + +model FrameworkEditorControlPolicyTemplateLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fcp'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + policyTemplateId String + policyTemplate FrameworkEditorPolicyTemplate @relation(fields: [policyTemplateId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, policyTemplateId]) + @@index([controlTemplateId]) + @@index([policyTemplateId]) +} + +model FrameworkEditorControlTaskTemplateLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fct'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + taskTemplateId String + taskTemplate FrameworkEditorTaskTemplate @relation(fields: [taskTemplateId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, taskTemplateId]) + @@index([controlTemplateId]) + @@index([taskTemplateId]) +} + +model FrameworkEditorControlDocumentTypeLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fcd'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + formType EvidenceFormType + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, formType]) + @@index([controlTemplateId]) +} diff --git a/packages/db/prisma/schema/framework.prisma b/packages/db/prisma/schema/framework.prisma index c7c5bb1962..171334e072 100644 --- a/packages/db/prisma/schema/framework.prisma +++ b/packages/db/prisma/schema/framework.prisma @@ -16,14 +16,17 @@ model FrameworkInstance { currentVersion FrameworkVersion? @relation("FrameworkInstanceCurrentVersion", fields: [currentVersionId], references: [id], onDelete: Restrict) // Relationships - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - requirementsMapped RequirementMap[] - timelineInstances TimelineInstance[] - syncOperations FrameworkSyncOperation[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requirementsMapped RequirementMap[] + timelineInstances TimelineInstance[] + syncOperations FrameworkSyncOperation[] + controlPolicyLinks FrameworkControlPolicyLink[] + controlTaskLinks FrameworkControlTaskLink[] + controlDocumentLinks FrameworkControlDocumentTypeLink[] // Per-instance custom requirements (used when an org tacks an extra requirement // onto a platform framework instance). Custom requirements attached to a // CustomFramework hang off CustomFramework.requirements instead. - customRequirements CustomRequirement[] + customRequirements CustomRequirement[] // (id, organizationId) is the composite-FK target for CustomRequirement.frameworkInstanceId // so a per-instance custom requirement can only point at an FI in its own org. @@ -33,3 +36,49 @@ model FrameworkInstance { @@index([customFrameworkId]) @@index([currentVersionId]) } + +model FrameworkControlPolicyLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fpl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + policyId String + policy Policy @relation(fields: [policyId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, policyId]) + @@index([controlId]) + @@index([policyId]) +} + +model FrameworkControlTaskLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('ftl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, taskId]) + @@index([controlId]) + @@index([taskId]) +} + +model FrameworkControlDocumentTypeLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fdl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + formType EvidenceFormType + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, formType]) + @@index([controlId]) +} diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 461ec471b6..50f1c87853 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -41,20 +41,21 @@ model Policy { archivedAt DateTime? // Relationships - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - assigneeId String? - assignee Member? @relation("PolicyAssignee", fields: [assigneeId], references: [id], onDelete: SetNull, onUpdate: Cascade) - approverId String? - approver Member? @relation("PolicyApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) - policyTemplateId String? - policyTemplate FrameworkEditorPolicyTemplate? @relation(fields: [policyTemplateId], references: [id]) - controls Control[] - currentVersionId String? @unique - currentVersion PolicyVersion? @relation("PolicyCurrentVersion", fields: [currentVersionId], references: [id]) - pendingVersionId String? - versions PolicyVersion[] @relation("PolicyVersions") - findings Finding[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + assigneeId String? + assignee Member? @relation("PolicyAssignee", fields: [assigneeId], references: [id], onDelete: SetNull, onUpdate: Cascade) + approverId String? + approver Member? @relation("PolicyApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) + policyTemplateId String? + policyTemplate FrameworkEditorPolicyTemplate? @relation(fields: [policyTemplateId], references: [id]) + controls Control[] + frameworkControlLinks FrameworkControlPolicyLink[] + currentVersionId String? @unique + currentVersion PolicyVersion? @relation("PolicyCurrentVersion", fields: [currentVersionId], references: [id]) + pendingVersionId String? + versions PolicyVersion[] @relation("PolicyVersions") + findings Finding[] @@index([organizationId]) @@index([organizationId, archivedAt]) diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index ed056588ef..3b054a0597 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -18,17 +18,18 @@ model Task { reviewDate DateTime? // Relationships - assigneeId String? - assignee Member? @relation(fields: [assigneeId], references: [id]) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - taskTemplateId String? - taskTemplate FrameworkEditorTaskTemplate? @relation(fields: [taskTemplateId], references: [id]) - controls Control[] - vendors Vendor[] - risks Risk[] - evidenceAutomations EvidenceAutomation[] - browserAutomations BrowserAutomation[] + assigneeId String? + assignee Member? @relation(fields: [assigneeId], references: [id]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + taskTemplateId String? + taskTemplate FrameworkEditorTaskTemplate? @relation(fields: [taskTemplateId], references: [id]) + controls Control[] + frameworkControlLinks FrameworkControlTaskLink[] + vendors Vendor[] + risks Risk[] + evidenceAutomations EvidenceAutomation[] + browserAutomations BrowserAutomation[] evidenceAutomationRuns EvidenceAutomationRun[] integrationCheckRuns IntegrationCheckRun[] diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b38d01aef5..cb75696ffd 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -20414,6 +20414,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -20490,6 +20498,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20539,6 +20555,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20637,6 +20661,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20694,6 +20726,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": {