diff --git a/apps/api/package.json b/apps/api/package.json index acf8fe92c4..ea7221c287 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -94,6 +94,7 @@ "better-auth": "^1.4.22", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "docx": "^9.7.1", "dotenv": "^17.2.3", "esbuild": "^0.27.1", "exceljs": "^4.4.0", @@ -101,6 +102,7 @@ "helmet": "^8.1.0", "jose": "^6.0.12", "jspdf": "^4.2.0", + "jspdf-autotable": "^5.0.8", "mammoth": "^1.8.0", "nanoid": "^5.1.6", "pdf-lib": "^1.17.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ccb8410374..079f0d222f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -24,6 +24,7 @@ import { VendorsModule } from './vendors/vendors.module'; import { ContextModule } from './context/context.module'; import { TrustPortalModule } from './trust-portal/trust-portal.module'; import { ControlTemplateModule } from './framework-editor/control-template/control-template.module'; +import { IsmsDocumentTemplateModule } from './framework-editor/isms-document-template/isms-document-template.module'; import { FrameworkEditorFrameworkModule } from './framework-editor/framework/framework.module'; import { PolicyTemplateModule } from './framework-editor/policy-template/policy-template.module'; import { RequirementModule } from './framework-editor/requirement/requirement.module'; @@ -34,6 +35,7 @@ import { QuestionnaireModule } from './questionnaire/questionnaire.module'; import { VectorStoreModule } from './vector-store/vector-store.module'; import { KnowledgeBaseModule } from './knowledge-base/knowledge-base.module'; import { SOAModule } from './soa/soa.module'; +import { IsmsModule } from './isms/isms.module'; import { IntegrationPlatformModule } from './integration-platform/integration-platform.module'; import { CloudSecurityModule } from './cloud-security/cloud-security.module'; import { BrowserbaseModule } from './browserbase/browserbase.module'; @@ -94,6 +96,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- HealthModule, TrustPortalModule, ControlTemplateModule, + IsmsDocumentTemplateModule, FrameworkEditorFrameworkModule, PolicyTemplateModule, RequirementModule, @@ -104,6 +107,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- VectorStoreModule, KnowledgeBaseModule, SOAModule, + IsmsModule, IntegrationPlatformModule, CloudSecurityModule, BrowserbaseModule, diff --git a/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts b/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts new file mode 100644 index 0000000000..6d059e3850 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateIsmsDocumentTemplateDto { + @ApiPropertyOptional({ example: 'Context of the Organization' }) + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ + example: 'Internal and external issues relevant to the ISMS.', + }) + @IsString() + @IsOptional() + @MaxLength(5000) + description?: string; + + @ApiPropertyOptional({ example: '4.1' }) + @IsString() + @IsOptional() + @MaxLength(32) + clause?: string; + + @ApiPropertyOptional({ example: 0 }) + @IsInt() + @IsOptional() + @Min(0) + sortOrder?: number; +} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts new file mode 100644 index 0000000000..3c018760f3 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts @@ -0,0 +1,102 @@ +jest.mock('@db', () => ({ db: {} })); + +import { Test, TestingModule } from '@nestjs/testing'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { IsmsDocumentTemplateController } from './isms-document-template.controller'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +jest.mock('../../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class MockPlatformAdminGuard {}, +})); + +describe('IsmsDocumentTemplateController', () => { + let controller: IsmsDocumentTemplateController; + + const mockService = { + findAll: jest.fn(), + update: jest.fn(), + linkRequirement: jest.fn(), + unlinkRequirement: jest.fn(), + linkControlTemplate: jest.fn(), + unlinkControlTemplate: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsDocumentTemplateController], + providers: [ + { provide: IsmsDocumentTemplateService, useValue: mockService }, + ], + }) + .overrideGuard(PlatformAdminGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(IsmsDocumentTemplateController); + jest.clearAllMocks(); + }); + + it('passes the frameworkId filter to findAll', async () => { + mockService.findAll.mockResolvedValue([]); + + await controller.findAll('fw_1'); + + expect(mockService.findAll).toHaveBeenCalledWith('fw_1'); + }); + + it('passes id and dto to update', async () => { + mockService.update.mockResolvedValue({ id: 'tpl_ctx' }); + + await controller.update('tpl_ctx', { name: 'New' }); + + expect(mockService.update).toHaveBeenCalledWith('tpl_ctx', { name: 'New' }); + }); + + it('maps params + query to linkRequirement', async () => { + mockService.linkRequirement.mockResolvedValue({ message: 'linked' }); + + await controller.linkRequirement('tpl_ctx', 'req_41', 'fw_1'); + + expect(mockService.linkRequirement).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to unlinkRequirement', async () => { + mockService.unlinkRequirement.mockResolvedValue({ message: 'unlinked' }); + + await controller.unlinkRequirement('tpl_ctx', 'req_41', 'fw_1'); + + expect(mockService.unlinkRequirement).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to linkControlTemplate', async () => { + mockService.linkControlTemplate.mockResolvedValue({ message: 'linked' }); + + await controller.linkControlTemplate('tpl_ctx', 'ct_1', 'fw_1'); + + expect(mockService.linkControlTemplate).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to unlinkControlTemplate', async () => { + mockService.unlinkControlTemplate.mockResolvedValue({ message: 'unlinked' }); + + await controller.unlinkControlTemplate('tpl_ctx', 'ct_1', 'fw_1'); + + expect(mockService.unlinkControlTemplate).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + }); +}); diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts new file mode 100644 index 0000000000..61a6488d41 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { UpdateIsmsDocumentTemplateDto } from './dto/update-isms-document-template.dto'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +@ApiTags('Framework Editor ISMS Document Templates') +@Controller({ path: 'framework-editor/isms-document-template', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class IsmsDocumentTemplateController { + constructor(private readonly service: IsmsDocumentTemplateService) {} + + @Get() + @ApiOperation({ summary: 'List ISMS document templates' }) + async findAll(@Query('frameworkId') frameworkId?: string) { + return this.service.findAll(frameworkId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an ISMS document template' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdateIsmsDocumentTemplateDto, + ) { + return this.service.update(id, dto); + } + + @Post(':id/requirements/:requirementId') + @ApiOperation({ + summary: 'Link a requirement to an ISMS document template for a framework', + }) + async linkRequirement( + @Param('id') id: string, + @Param('requirementId') requirementId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.linkRequirement({ + templateId: id, + requirementId, + frameworkId, + }); + } + + @Delete(':id/requirements/:requirementId') + @ApiOperation({ + summary: + 'Unlink a requirement from an ISMS document template for a framework', + }) + async unlinkRequirement( + @Param('id') id: string, + @Param('requirementId') requirementId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.unlinkRequirement({ + templateId: id, + requirementId, + frameworkId, + }); + } + + @Post(':id/controls/:controlTemplateId') + @ApiOperation({ + summary: + 'Link a control template to an ISMS document template for a framework', + }) + async linkControlTemplate( + @Param('id') id: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.linkControlTemplate({ + templateId: id, + controlTemplateId, + frameworkId, + }); + } + + @Delete(':id/controls/:controlTemplateId') + @ApiOperation({ + summary: + 'Unlink a control template from an ISMS document template for a framework', + }) + async unlinkControlTemplate( + @Param('id') id: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.unlinkControlTemplate({ + templateId: id, + controlTemplateId, + frameworkId, + }); + } +} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts new file mode 100644 index 0000000000..52e09b13f3 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { IsmsDocumentTemplateController } from './isms-document-template.controller'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +@Module({ + imports: [AuthModule], + controllers: [IsmsDocumentTemplateController], + providers: [IsmsDocumentTemplateService], + exports: [IsmsDocumentTemplateService], +}) +export class IsmsDocumentTemplateModule {} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts new file mode 100644 index 0000000000..1d59b4fb9c --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts @@ -0,0 +1,294 @@ +jest.mock('@db', () => { + const dbMock = { + frameworkEditorIsmsDocumentTemplate: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + frameworkEditorIsmsDocumentRequirementLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlIsmsDocumentLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlTemplate: { + findUnique: jest.fn(), + }, + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + frameworkEditorRequirement: { + findUnique: jest.fn(), + }, + }; + + return { db: dbMock }; +}); + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +const mockDb = db as jest.Mocked; + +describe('IsmsDocumentTemplateService', () => { + let service: IsmsDocumentTemplateService; + + beforeEach(() => { + service = new IsmsDocumentTemplateService(); + jest.clearAllMocks(); + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findUnique as jest.Mock + ).mockResolvedValue({ id: 'tpl_ctx' }); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'fw_1', + }); + ( + mockDb.frameworkEditorRequirement.findUnique as jest.Mock + ).mockResolvedValue({ frameworkId: 'fw_1' }); + ( + mockDb.frameworkEditorIsmsDocumentRequirementLink.createMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorIsmsDocumentRequirementLink.deleteMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock + ).mockResolvedValue({ id: 'ct_1' }); + ( + mockDb.frameworkEditorControlIsmsDocumentLink.createMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorControlIsmsDocumentLink.deleteMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + }); + + describe('findAll', () => { + it('orders by sortOrder and includes all requirement links when no framework filter', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mockResolvedValue([{ id: 'tpl_ctx', requirementLinks: [] }]); + + const result = await service.findAll(); + + expect(result).toEqual([{ id: 'tpl_ctx', requirementLinks: [] }]); + const callArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mock.calls[0][0]; + expect(callArgs.orderBy).toEqual({ sortOrder: 'asc' }); + expect(callArgs.include.requirementLinks.where).toBeUndefined(); + expect(callArgs.include.controlLinks.where).toBeUndefined(); + }); + + it('scopes requirement and control links to the given framework', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mockResolvedValue([]); + + await service.findAll('fw_1'); + + const callArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mock.calls[0][0]; + expect(callArgs.include.requirementLinks.where).toEqual({ + frameworkId: 'fw_1', + }); + expect(callArgs.include.controlLinks.where).toEqual({ + frameworkId: 'fw_1', + }); + expect(callArgs.include.controlLinks.select.controlTemplate).toEqual({ + select: { id: true, name: true }, + }); + }); + }); + + describe('update', () => { + it('throws NotFoundException when the template does not exist', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.update('tpl_missing', { name: 'x' }), + ).rejects.toThrow(NotFoundException); + }); + + it('persists only the provided fields', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.update as jest.Mock + ).mockResolvedValue({ id: 'tpl_ctx', name: 'New name' }); + + await service.update('tpl_ctx', { name: 'New name', sortOrder: 3 }); + + const updateArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.update as jest.Mock + ).mock.calls[0][0]; + expect(updateArgs).toEqual({ + where: { id: 'tpl_ctx' }, + data: { name: 'New name', sortOrder: 3 }, + }); + }); + }); + + describe('linkRequirement', () => { + it('requires a frameworkId', async () => { + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when the framework is missing', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_missing', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects a requirement from another framework', async () => { + ( + mockDb.frameworkEditorRequirement.findUnique as jest.Mock + ).mockResolvedValue({ frameworkId: 'fw_other' }); + + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('creates the framework-scoped link idempotently', async () => { + await service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorIsmsDocumentRequirementLink.createMany, + ).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + requirementId: 'req_41', + }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('unlinkRequirement', () => { + it('deletes the framework-scoped link', async () => { + await service.unlinkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorIsmsDocumentRequirementLink.deleteMany, + ).toHaveBeenCalledWith({ + where: { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + requirementId: 'req_41', + }, + }); + }); + }); + + describe('linkControlTemplate', () => { + it('requires a frameworkId', async () => { + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when the framework is missing', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_missing', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when the control template is missing', async () => { + ( + mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_missing', + frameworkId: 'fw_1', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('creates the framework-scoped link idempotently', async () => { + await service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorControlIsmsDocumentLink.createMany, + ).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('unlinkControlTemplate', () => { + it('deletes the framework-scoped link', async () => { + await service.unlinkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorControlIsmsDocumentLink.deleteMany, + ).toHaveBeenCalledWith({ + where: { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }, + }); + }); + }); +}); diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts new file mode 100644 index 0000000000..883c6fb39e --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts @@ -0,0 +1,240 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { UpdateIsmsDocumentTemplateDto } from './dto/update-isms-document-template.dto'; + +/** + * CRUD + framework-scoped requirement mapping for the ISMS foundational + * document templates (CS-437). Mirrors ControlTemplateService: the 6 templates + * are enum-fixed (seeded), so this exposes list + update + requirement + * link/unlink rather than create/delete. + */ +@Injectable() +export class IsmsDocumentTemplateService { + private readonly logger = new Logger(IsmsDocumentTemplateService.name); + + async findAll(frameworkId?: string) { + return db.frameworkEditorIsmsDocumentTemplate.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { + requirementLinks: { + ...(frameworkId ? { where: { frameworkId } } : {}), + select: { + id: true, + frameworkId: true, + requirementId: true, + requirement: { + select: { + id: true, + name: true, + identifier: true, + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + controlLinks: { + ...(frameworkId ? { where: { frameworkId } } : {}), + select: { + id: true, + frameworkId: true, + controlTemplateId: true, + controlTemplate: { select: { id: true, name: true } }, + }, + }, + }, + }); + } + + async update(id: string, dto: UpdateIsmsDocumentTemplateDto) { + await this.requireTemplate(id); + const updated = await db.frameworkEditorIsmsDocumentTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.clause !== undefined && { clause: dto.clause }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }, + }); + this.logger.log(`Updated ISMS document template: ${updated.name} (${id})`); + return updated; + } + + async linkRequirement({ + templateId, + requirementId, + frameworkId, + }: { + templateId: string; + requirementId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await this.ensureRequirement({ + requirementId, + frameworkId: scopedFrameworkId, + }); + await db.frameworkEditorIsmsDocumentRequirementLink.createMany({ + data: [ + { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + requirementId, + }, + ], + skipDuplicates: true, + }); + return { message: 'Requirement linked' }; + } + + async unlinkRequirement({ + templateId, + requirementId, + frameworkId, + }: { + templateId: string; + requirementId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await db.frameworkEditorIsmsDocumentRequirementLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + requirementId, + }, + }); + return { message: 'Requirement unlinked' }; + } + + async linkControlTemplate({ + templateId, + controlTemplateId, + frameworkId, + }: { + templateId: string; + controlTemplateId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await this.ensureControlTemplate(controlTemplateId); + await db.frameworkEditorControlIsmsDocumentLink.createMany({ + data: [ + { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + controlTemplateId, + }, + ], + skipDuplicates: true, + }); + return { message: 'Control template linked' }; + } + + async unlinkControlTemplate({ + templateId, + controlTemplateId, + frameworkId, + }: { + templateId: string; + controlTemplateId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await db.frameworkEditorControlIsmsDocumentLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + controlTemplateId, + }, + }); + return { message: 'Control template unlinked' }; + } + + private async requireTemplate(id: string) { + const template = await db.frameworkEditorIsmsDocumentTemplate.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!template) { + throw new NotFoundException(`ISMS document template ${id} not found`); + } + return template; + } + + private async ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }: { + templateId: string; + frameworkId?: string; + }): Promise { + const scopedFrameworkId = await this.ensureFramework(frameworkId); + await this.requireTemplate(templateId); + return scopedFrameworkId; + } + + private async ensureFramework(frameworkId?: string): Promise { + if (!frameworkId) { + throw new BadRequestException( + 'frameworkId is required to map a requirement', + ); + } + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); + if (!framework) throw new NotFoundException('Framework not found'); + return frameworkId; + } + + private async ensureRequirement({ + requirementId, + frameworkId, + }: { + requirementId: string; + frameworkId: string; + }): Promise { + const requirement = await db.frameworkEditorRequirement.findUnique({ + where: { id: requirementId }, + select: { frameworkId: true }, + }); + if (!requirement) { + throw new NotFoundException(`Requirement ${requirementId} not found`); + } + if (requirement.frameworkId !== frameworkId) { + throw new BadRequestException( + 'Requirement does not belong to the given framework', + ); + } + } + + private async ensureControlTemplate(controlTemplateId: string): Promise { + const control = await db.frameworkEditorControlTemplate.findUnique({ + where: { id: controlTemplateId }, + select: { id: true }, + }); + if (!control) { + throw new NotFoundException( + `Control template ${controlTemplateId} not found`, + ); + } + } +} diff --git a/apps/api/src/isms/documents/context.spec.ts b/apps/api/src/isms/documents/context.spec.ts new file mode 100644 index 0000000000..d1ce7c3f4c --- /dev/null +++ b/apps/api/src/isms/documents/context.spec.ts @@ -0,0 +1,138 @@ +import { buildContextSections } from './context'; +import type { DocumentExportInput } from './types'; + +const input: DocumentExportInput = { + contextIssues: [ + // Deliberately out of category order to prove the section sorts them. + { + kind: 'external', + category: 'Technological', + description: 'Reliance on cloud vendors', + effect: 'Extends the ISMS boundary', + }, + { + kind: 'external', + category: 'Regulatory & Legal', + description: 'ISO 27001 obligations', + effect: 'Shapes ISMS objectives and audit scope', + }, + { + kind: 'internal', + category: 'Capabilities & Resources', + description: 'Managed endpoints', + effect: 'Drives device-management controls', + }, + { + kind: 'internal', + category: 'Governance & Structure', + description: 'A workforce of 12 members', + effect: 'Determines awareness and access needs', + }, + ], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + orgProfile: { + overview: [ + { label: 'Legal entity', value: 'Acme Inc' }, + { label: 'Website', value: 'https://acme.io' }, + { label: 'Industry', value: 'SaaS' }, + ], + mission: 'We build secure compliance tooling.', + intendedOutcomes: [ + 'Protect the confidentiality, integrity and availability of information.', + 'Meet legal and contractual obligations.', + ], + }, +}; + +describe('buildContextSections', () => { + it('returns the full 7-section structure with numbered headings', () => { + const sections = buildContextSections(input); + expect(sections.map((s) => s.heading)).toEqual([ + '1. Purpose', + '2. Organization overview', + '3. Mission and intended outcomes of the ISMS', + '4. External issues (4.1)', + '5. Internal issues (4.1)', + '6. Linkage to the ISMS', + '7. Review', + ]); + }); + + it('uses the legal entity name in the purpose narrative', () => { + const sections = buildContextSections(input); + expect(sections[0].paragraphs?.[0].text).toContain('Acme Inc'); + }); + + it('renders the organization overview as key/value rows', () => { + const overview = buildContextSections(input)[1]; + expect(overview.keyValues).toEqual(input.orgProfile?.overview); + }); + + it('renders the mission and intended-outcome bullets in section 3', () => { + const mission = buildContextSections(input)[2]; + expect(mission.paragraphs?.some((p) => p.text === '3.1 Mission')).toBe(true); + expect( + mission.paragraphs?.some( + (p) => p.text === 'We build secure compliance tooling.', + ), + ).toBe(true); + expect(mission.bullets).toEqual(input.orgProfile?.intendedOutcomes); + }); + + it('builds the external issues table with the categorised headers', () => { + const external = buildContextSections(input)[3]; + expect(external.table?.headers).toEqual([ + 'Category', + 'External issue', + 'Effect on the ability to achieve ISMS objectives', + ]); + }); + + it('sorts external issue rows by category and includes the category value', () => { + const external = buildContextSections(input)[3]; + expect(external.table?.rows).toEqual([ + [ + 'Regulatory & Legal', + 'ISO 27001 obligations', + 'Shapes ISMS objectives and audit scope', + ], + [ + 'Technological', + 'Reliance on cloud vendors', + 'Extends the ISMS boundary', + ], + ]); + }); + + it('builds the internal issues table sorted by category', () => { + const internal = buildContextSections(input)[4]; + expect(internal.table?.headers).toEqual([ + 'Category', + 'Internal issue', + 'Effect on the ability to achieve ISMS objectives', + ]); + expect(internal.table?.rows.map((row) => row[0])).toEqual([ + 'Governance & Structure', + 'Capabilities & Resources', + ]); + }); + + it('provides linkage bullets in section 6', () => { + const linkage = buildContextSections(input)[5]; + expect(linkage.bullets?.length).toBeGreaterThan(0); + expect(linkage.bullets?.some((b) => b.includes('Clause 4.2'))).toBe(true); + }); + + it('falls back gracefully when no org profile is supplied', () => { + const sections = buildContextSections({ + ...input, + orgProfile: undefined, + }); + expect(sections).toHaveLength(7); + expect(sections[1].keyValues).toEqual([]); + expect(sections[0].paragraphs?.[0].text).toContain('the organization'); + }); +}); diff --git a/apps/api/src/isms/documents/context.ts b/apps/api/src/isms/documents/context.ts new file mode 100644 index 0000000000..a451917479 --- /dev/null +++ b/apps/api/src/isms/documents/context.ts @@ -0,0 +1,182 @@ +import { + deriveContextIssues, + EXTERNAL_ISSUE_CATEGORIES, + INTERNAL_ISSUE_CATEGORIES, + type ContextDerivationInput, +} from '../utils/context-derivation'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Adapt the shared platform data to the 4.1 derivation input. */ +function toContextInput(data: IsmsPlatformData): ContextDerivationInput { + return { + frameworkNames: data.frameworkNames, + vendorCount: data.vendorCount, + subProcessorCount: data.subProcessorCount, + vendorsByCategory: data.vendorsByCategory, + memberCount: data.memberCount, + membersByDepartment: data.membersByDepartment, + deviceCount: data.deviceCount, + }; +} + +/** Re-export the 4.1 derivation under the per-document module. */ +export function deriveContextOfOrganization(data: IsmsPlatformData) { + return deriveContextIssues(toContextInput(data)); +} + +const STANDARD = 'ISO/IEC 27001:2022'; + +type ContextIssue = DocumentExportInput['contextIssues'][number]; + +function orgNameFrom(input: DocumentExportInput): string { + const entry = input.orgProfile?.overview.find( + (row) => row.label === 'Legal entity', + ); + return entry?.value || 'the organization'; +} + +function purposeSection(orgName: string): IsmsExportSection { + return { + heading: '1. Purpose', + paragraphs: [ + { + text: `This document determines the external and internal issues relevant to the purpose of ${orgName} that affect its ability to achieve the intended outcomes of its Information Security Management System (ISMS), in accordance with ${STANDARD}, Clause 4.1.`, + }, + { + text: "For each issue, the effect on the organization's ability to achieve the objectives of its ISMS is stated explicitly. The document is reviewed at least annually and whenever a material change occurs (strategy, technology stack, regulatory environment, key personnel, or significant incidents).", + }, + ], + }; +} + +function overviewSection(input: DocumentExportInput): IsmsExportSection { + return { + heading: '2. Organization overview', + keyValues: input.orgProfile?.overview ?? [], + emptyText: 'Organization details have not been captured yet.', + }; +} + +function missionSection(input: DocumentExportInput): IsmsExportSection { + const profile = input.orgProfile; + const mission = profile?.mission ?? null; + const intendedOutcomes = profile?.intendedOutcomes ?? []; + + const paragraphs: IsmsExportSection['paragraphs'] = []; + if (mission) { + paragraphs.push({ text: '3.1 Mission', bold: true }); + paragraphs.push({ text: mission }); + } + if (intendedOutcomes.length > 0) { + paragraphs.push({ text: '3.2 Intended outcomes of the ISMS', bold: true }); + } + + return { + heading: '3. Mission and intended outcomes of the ISMS', + paragraphs, + bullets: intendedOutcomes.length > 0 ? intendedOutcomes : undefined, + emptyText: 'No mission or intended outcomes recorded.', + }; +} + +function issuesSection({ + heading, + intro, + issueColumnLabel, + categories, + issues, +}: { + heading: string; + intro: string; + issueColumnLabel: string; + categories: readonly string[]; + issues: ContextIssue[]; +}): IsmsExportSection { + const order = new Map(categories.map((category, index) => [category, index])); + const sorted = [...issues].sort( + (a, b) => + (order.get(a.category ?? '') ?? categories.length) - + (order.get(b.category ?? '') ?? categories.length), + ); + + return { + heading, + intro, + emptyText: 'No issues recorded.', + table: { + headers: [ + 'Category', + issueColumnLabel, + 'Effect on the ability to achieve ISMS objectives', + ], + rows: sorted.map((issue) => [ + issue.category ?? '—', + issue.description, + issue.effect, + ]), + }, + }; +} + +function linkageSection(): IsmsExportSection { + return { + heading: '6. Linkage to the ISMS', + intro: 'The issues above feed directly into:', + bullets: [ + 'Clause 4.2 — Interested parties and their requirements.', + 'Clause 4.3 — ISMS scope, including interfaces and dependencies with cloud providers and other sub-processors.', + 'Clause 5 — Leadership, policy, and roles & responsibilities.', + 'Clause 6.1 — Information security risk assessment and risk treatment.', + 'Clause 9.3 — Management review inputs (changes to external and internal issues).', + ], + }; +} + +function reviewSection(): IsmsExportSection { + return { + heading: '7. Review', + paragraphs: [ + { + text: 'Owner: the Security & Privacy Owner. The document is reviewed at least annually and on material change. Outputs feed the Management Review per Clause 9.3.', + }, + ], + }; +} + +/** + * Build the Context of the Organization (clause 4.1) export as the full + * auditor-ready document: purpose, organization overview, mission & intended + * outcomes, the categorised external/internal issue tables (each with the + * "effect on ISMS objectives" column), linkage to the ISMS, and review. + */ +export function buildContextSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const external = input.contextIssues.filter((i) => i.kind === 'external'); + const internal = input.contextIssues.filter((i) => i.kind === 'internal'); + + return [ + purposeSection(orgNameFrom(input)), + overviewSection(input), + missionSection(input), + issuesSection({ + heading: '4. External issues (4.1)', + intro: + 'External issues are organised under the categories of regulatory & legal, market & economic, technological, and social & cultural. For each, the effect on the ability to achieve the ISMS objectives is stated.', + issueColumnLabel: 'External issue', + categories: EXTERNAL_ISSUE_CATEGORIES, + issues: external, + }), + issuesSection({ + heading: '5. Internal issues (4.1)', + intro: + 'Internal issues are organised under the categories of governance & structure, strategy & objectives, capabilities & resources, and culture & values.', + issueColumnLabel: 'Internal issue', + categories: INTERNAL_ISSUE_CATEGORIES, + issues: internal, + }), + linkageSection(), + reviewSection(), + ]; +} diff --git a/apps/api/src/isms/documents/data-source.spec.ts b/apps/api/src/isms/documents/data-source.spec.ts new file mode 100644 index 0000000000..279dd5420d --- /dev/null +++ b/apps/api/src/isms/documents/data-source.spec.ts @@ -0,0 +1,192 @@ +// Mock @db before importing the unit under test so collectPlatformData reads +// from these fakes. We only stub the table methods the function actually calls. +const mockDb = { + organization: { findUnique: jest.fn() }, + frameworkInstance: { findMany: jest.fn() }, + vendor: { findMany: jest.fn() }, + member: { count: jest.fn(), groupBy: jest.fn() }, + device: { count: jest.fn() }, + risk: { findMany: jest.fn() }, + employeeTrainingVideoCompletion: { count: jest.fn() }, + frameworkEditorFramework: { findUnique: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, + ismsInterestedParty: { findMany: jest.fn() }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { collectPlatformData } from './data-source'; + +type VendorRow = { name: string; category: string; isSubProcessor: boolean }; +type RiskRow = { residualLikelihood: string; residualImpact: string }; +type PartyRow = { id: string; name: string; category: string }; + +const ARGS = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +function seedDb({ + vendors = [], + membersGrouped = [], + risks = [], + parties = [], + memberCount = 0, + deviceCount = 0, + trainingCount = 0, +}: { + vendors?: VendorRow[]; + membersGrouped?: Array<{ department: string; _count: { _all: number } }>; + risks?: RiskRow[]; + parties?: PartyRow[]; + memberCount?: number; + deviceCount?: number; + trainingCount?: number; +}) { + mockDb.organization.findUnique.mockResolvedValue({ name: ' Acme Corp ' }); + mockDb.frameworkInstance.findMany.mockResolvedValue([ + { framework: { name: 'SOC 2' } }, + ]); + mockDb.vendor.findMany.mockResolvedValue(vendors); + mockDb.member.count.mockResolvedValue(memberCount); + mockDb.member.groupBy.mockResolvedValue(membersGrouped); + mockDb.device.count.mockResolvedValue(deviceCount); + mockDb.risk.findMany.mockResolvedValue(risks); + mockDb.employeeTrainingVideoCompletion.count.mockResolvedValue(trainingCount); + mockDb.frameworkEditorFramework.findUnique.mockResolvedValue({ + name: 'ISO 27001', + }); + mockDb.ismsProfile.findUnique.mockResolvedValue({ answers: {} }); + mockDb.ismsInterestedParty.findMany.mockResolvedValue(parties); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('collectPlatformData', () => { + it('counts only high-likelihood AND high-impact risks as high risk', async () => { + seedDb({ + risks: [ + { residualLikelihood: 'likely', residualImpact: 'major' }, // high+high -> counts + { residualLikelihood: 'very_likely', residualImpact: 'severe' }, // counts + { residualLikelihood: 'very_likely', residualImpact: 'minor' }, // high likelihood only + { residualLikelihood: 'unlikely', residualImpact: 'severe' }, // high impact only + { residualLikelihood: 'unlikely', residualImpact: 'minor' }, // neither + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.riskCount).toBe(5); + expect(data.highRiskCount).toBe(2); + }); + + it('groups vendors by category and tracks sub-processors and infra vendors', async () => { + seedDb({ + vendors: [ + { name: 'AWS', category: 'cloud', isSubProcessor: true }, + { name: 'GCP', category: 'infrastructure', isSubProcessor: false }, + { name: 'Stripe', category: 'software_as_a_service', isSubProcessor: true }, + { name: 'Acme HR', category: 'hr', isSubProcessor: false }, + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.vendorCount).toBe(4); + expect(data.vendorsByCategory).toEqual({ + cloud: 1, + infrastructure: 1, + software_as_a_service: 1, + hr: 1, + }); + // sub-processors are AWS + Stripe, returned sorted. + expect(data.subProcessorCount).toBe(2); + expect(data.subProcessorNames).toEqual(['AWS', 'Stripe']); + // infra/cloud categories only (not the hr vendor), sorted. + expect(data.infraVendorNames).toEqual(['AWS', 'GCP', 'Stripe']); + }); + + it('groups members by department from the groupBy aggregation', async () => { + seedDb({ + memberCount: 5, + membersGrouped: [ + { department: 'it', _count: { _all: 3 } }, + { department: 'hr', _count: { _all: 2 } }, + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.memberCount).toBe(5); + expect(data.membersByDepartment).toEqual({ it: 3, hr: 2 }); + }); + + it('merges framework instance names with the requested framework, sorted', async () => { + seedDb({}); + + const data = await collectPlatformData(ARGS); + + // SOC 2 (instance) + ISO 27001 (ownFramework), de-duped and sorted. + expect(data.frameworkNames).toEqual(['ISO 27001', 'SOC 2']); + }); + + it('trims the organization name and flags the training program', async () => { + seedDb({ trainingCount: 4 }); + + const data = await collectPlatformData(ARGS); + + expect(data.organizationName).toBe('Acme Corp'); + expect(data.hasTrainingProgram).toBe(true); + }); + + it('falls back to a default org name and no training when empty', async () => { + seedDb({ trainingCount: 0 }); + mockDb.organization.findUnique.mockResolvedValue({ name: ' ' }); + + const data = await collectPlatformData(ARGS); + + expect(data.organizationName).toBe('The organization'); + expect(data.hasTrainingProgram).toBe(false); + }); + + it('produces an order-insensitive parties fingerprint', async () => { + const partiesA: PartyRow[] = [ + { id: 'p1', name: 'Customers', category: 'external' }, + { id: 'p2', name: 'Regulators', category: 'external' }, + ]; + const partiesReordered: PartyRow[] = [partiesA[1], partiesA[0]]; + + seedDb({ parties: partiesA }); + const a = await collectPlatformData(ARGS); + + seedDb({ parties: partiesReordered }); + const b = await collectPlatformData(ARGS); + + expect(a.partiesFingerprint).toEqual(b.partiesFingerprint); + expect(a.partiesFingerprint).not.toBe(''); + }); + + it('changes the fingerprint when a party is edited', async () => { + const original: PartyRow[] = [ + { id: 'p1', name: 'Customers', category: 'external' }, + ]; + const edited: PartyRow[] = [ + { id: 'p1', name: 'Customers (key accounts)', category: 'external' }, + ]; + + seedDb({ parties: original }); + const before = await collectPlatformData(ARGS); + + seedDb({ parties: edited }); + const after = await collectPlatformData(ARGS); + + expect(after.partiesFingerprint).not.toBe(before.partiesFingerprint); + }); + + it('returns an empty fingerprint when no parties exist', async () => { + seedDb({ parties: [] }); + + const data = await collectPlatformData(ARGS); + + expect(data.partiesFingerprint).toBe(''); + }); +}); diff --git a/apps/api/src/isms/documents/data-source.ts b/apps/api/src/isms/documents/data-source.ts new file mode 100644 index 0000000000..f8db007e40 --- /dev/null +++ b/apps/api/src/isms/documents/data-source.ts @@ -0,0 +1,147 @@ +import { createHash } from 'node:crypto'; +import { db } from '@db'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsPlatformData } from './types'; + +const CLOUD_CATEGORIES = ['cloud', 'infrastructure', 'software_as_a_service']; +const HIGH_LIKELIHOOD = ['likely', 'very_likely']; +const HIGH_IMPACT = ['major', 'severe']; + +/** + * Reads all platform data used to derive the ISMS foundational documents for a + * single organization. Always scoped by organizationId. The returned shape is + * the raw snapshot — derivation logic lives in the per-document handlers so this + * file only owns the queries. + */ +export async function collectPlatformData({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [ + organization, + frameworkInstances, + vendors, + memberCount, + membersGrouped, + deviceCount, + risks, + trainingCompletionCount, + ownFramework, + profile, + partiesRows, + ] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.frameworkInstance.findMany({ + where: { organizationId }, + select: { framework: { select: { name: true } } }, + }), + db.vendor.findMany({ + where: { organizationId }, + select: { name: true, category: true, isSubProcessor: true }, + }), + db.member.count({ where: { organizationId, deactivated: false } }), + db.member.groupBy({ + by: ['department'], + where: { organizationId, deactivated: false }, + _count: { _all: true }, + }), + db.device.count({ where: { organizationId } }), + db.risk.findMany({ + where: { organizationId }, + select: { residualLikelihood: true, residualImpact: true }, + }), + db.employeeTrainingVideoCompletion.count({ + where: { member: { organizationId } }, + }), + db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { name: true }, + }), + db.ismsProfile.findUnique({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + select: { answers: true }, + }), + db.ismsInterestedParty.findMany({ + where: { + document: { + organizationId, + frameworkId, + type: 'interested_parties_register', + }, + }, + select: { id: true, name: true, category: true }, + }), + ]); + + const frameworkNames = new Set(); + for (const instance of frameworkInstances) { + if (instance.framework?.name) frameworkNames.add(instance.framework.name); + } + if (ownFramework?.name) frameworkNames.add(ownFramework.name); + + const vendorsByCategory: Record = {}; + const subProcessorNames: string[] = []; + const infraVendorNames: string[] = []; + for (const vendor of vendors) { + vendorsByCategory[vendor.category] = + (vendorsByCategory[vendor.category] ?? 0) + 1; + if (vendor.isSubProcessor) subProcessorNames.push(vendor.name); + if (CLOUD_CATEGORIES.includes(vendor.category)) { + infraVendorNames.push(vendor.name); + } + } + + const membersByDepartment: Record = {}; + for (const row of membersGrouped) { + membersByDepartment[row.department] = row._count._all; + } + + const highRiskCount = risks.filter( + (risk) => + HIGH_LIKELIHOOD.includes(risk.residualLikelihood) && + HIGH_IMPACT.includes(risk.residualImpact), + ).length; + + return { + organizationName: organization?.name?.trim() || 'The organization', + frameworkNames: Array.from(frameworkNames).sort(), + vendorCount: vendors.length, + subProcessorCount: subProcessorNames.length, + vendorsByCategory, + subProcessorNames: subProcessorNames.sort(), + infraVendorNames: infraVendorNames.sort(), + memberCount, + membersByDepartment, + deviceCount, + riskCount: risks.length, + highRiskCount, + hasTrainingProgram: trainingCompletionCount > 0, + wizardAnswers: parseStoredAnswers(profile?.answers), + partiesFingerprint: fingerprintParties(partiesRows), + }; +} + +/** + * Stable, order-insensitive SHA-256 of the parties register rows. The + * Requirements document derives one row per party, so a manual party edit (name + * or category) — otherwise invisible to the platform snapshot — must change this + * fingerprint and flag requirements drift. Each row is JSON-encoded (so field + * boundaries can never collide) and the encoded rows are sorted, making the + * result independent of row order. + */ +function fingerprintParties( + rows: Array<{ id: string; name: string; category: string }>, +): string { + if (rows.length === 0) return ''; + const canonical = rows + .map((row) => JSON.stringify([row.id, row.name, row.category])) + .sort() + .join(''); + return createHash('sha256').update(canonical).digest('hex'); +} diff --git a/apps/api/src/isms/documents/generate.spec.ts b/apps/api/src/isms/documents/generate.spec.ts new file mode 100644 index 0000000000..d4eb81cdec --- /dev/null +++ b/apps/api/src/isms/documents/generate.spec.ts @@ -0,0 +1,155 @@ +import type { Prisma } from '@db'; +import { runDerivation } from './generate'; +import type { IsmsPlatformData } from './types'; + +/** Treat a hand-rolled mock as a transaction client without an `as any` cast. */ +function asTx(mock: unknown): Prisma.TransactionClient { + return mock as Prisma.TransactionClient; +} + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +function registerTable() { + return { + deleteMany: jest.fn().mockResolvedValue({}), + count: jest.fn().mockResolvedValue(2), // two manual rows preserved + createMany: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }; +} + +function buildTx() { + return { + ismsContextIssue: registerTable(), + ismsInterestedParty: registerTable(), + ismsInterestedPartyRequirement: registerTable(), + ismsObjective: registerTable(), + ismsDocument: { findFirst: jest.fn().mockResolvedValue(null) }, + ismsDocumentVersion: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + }; +} + +const baseArgs = { + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data, +}; + +describe('runDerivation', () => { + it('writes context issues after preserved manual rows', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'context_of_organization', + ...baseArgs, + }); + expect(tx.ismsContextIssue.deleteMany).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', source: 'derived' }, + }); + const created = tx.ismsContextIssue.createMany.mock.calls[0][0].data; + expect(created[0].position).toBe(2); + }); + + it('writes interested parties', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_register', + ...baseArgs, + }); + expect(tx.ismsInterestedParty.createMany).toHaveBeenCalled(); + const created = tx.ismsInterestedParty.createMany.mock.calls[0][0].data; + expect(created[0].position).toBe(2); + }); + + it('threads wizard answers into the interested-parties register (CS-438)', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_register', + ...baseArgs, + data: { + ...data, + wizardAnswers: { insurance: { has: true, insurerName: 'Acme Cyber' } }, + }, + }); + const created = tx.ismsInterestedParty.createMany.mock.calls[0][0].data; + expect( + created.some( + (row: { derivedFrom: string }) => + row.derivedFrom === 'wizard:insurance', + ), + ).toBe(true); + }); + + it('reads the register doc to derive requirements', async () => { + const tx = buildTx(); + tx.ismsDocument.findFirst.mockResolvedValue({ id: 'reg_1' }); + tx.ismsInterestedParty.findMany.mockResolvedValue([ + { id: 'ip_1', name: 'Customers', category: 'Customer' }, + ]); + + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_requirements', + ...baseArgs, + }); + expect(tx.ismsDocument.findFirst).toHaveBeenCalledWith({ + where: { + organizationId: 'org_1', + frameworkId: 'fw_1', + type: 'interested_parties_register', + }, + select: { id: true }, + }); + expect(tx.ismsInterestedPartyRequirement.createMany).toHaveBeenCalled(); + }); + + it('writes objectives', async () => { + const tx = buildTx(); + await runDerivation({ tx: asTx(tx), type: 'objectives_plan', ...baseArgs }); + expect(tx.ismsObjective.createMany).toHaveBeenCalled(); + }); + + it('creates a version with the derived narrative for isms_scope', async () => { + const tx = buildTx(); + await runDerivation({ tx: asTx(tx), type: 'isms_scope', ...baseArgs }); + expect(tx.ismsDocumentVersion.create).toHaveBeenCalled(); + const created = tx.ismsDocumentVersion.create.mock.calls[0][0].data; + expect(created.narrative.certificateScopeSentence).toBeDefined(); + }); + + it('updates the existing version narrative for leadership', async () => { + const tx = buildTx(); + tx.ismsDocumentVersion.findFirst.mockResolvedValue({ id: 'ver_1' }); + await runDerivation({ + tx: asTx(tx), + type: 'leadership_commitment', + ...baseArgs, + }); + expect(tx.ismsDocumentVersion.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'ver_1' } }), + ); + }); +}); diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts new file mode 100644 index 0000000000..4554fbe2b9 --- /dev/null +++ b/apps/api/src/isms/documents/generate.ts @@ -0,0 +1,261 @@ +import { BadRequestException } from '@nestjs/common'; +import type { IsmsDocumentType, Prisma } from '@db'; +import { deriveContextOfOrganization } from './context'; +import { deriveInterestedParties } from './interested-parties'; +import { deriveRequirements } from './requirements'; +import { deriveObjectives } from './objectives'; +import { deriveNarrativeForType, isNarrativeType } from './registry'; +import type { IsmsPlatformData } from './types'; + +type Tx = Prisma.TransactionClient; + +/** + * Replace the derived rows of a register, preserving manual rows and appending the + * derived rows after them (same pattern as the 4.1 context register). + */ +async function replaceDerivedRows({ + derived, + deleteDerived, + countManual, + createMany, +}: { + derived: T[]; + deleteDerived: () => Promise; + countManual: () => Promise; + createMany: (args: { rows: T[]; manualCount: number }) => Promise; +}): Promise { + await deleteDerived(); + const manualCount = await countManual(); + if (derived.length > 0) { + await createMany({ rows: derived, manualCount }); + } +} + +async function generateInterestedParties({ + tx, + documentId, + data, +}: { + tx: Tx; + documentId: string; + data: IsmsPlatformData; +}): Promise { + const derived = deriveInterestedParties(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsInterestedParty.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsInterestedParty.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsInterestedParty.createMany({ + data: rows.map((row, index) => ({ + documentId, + name: row.name, + category: row.category, + needsExpectations: row.needsExpectations, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +async function generateRequirements({ + tx, + documentId, + organizationId, + frameworkId, + data, +}: { + tx: Tx; + documentId: string; + organizationId: string; + frameworkId: string; + data: IsmsPlatformData; +}): Promise { + const registerDoc = await tx.ismsDocument.findFirst({ + where: { organizationId, frameworkId, type: 'interested_parties_register' }, + select: { id: true }, + }); + const parties = registerDoc + ? await tx.ismsInterestedParty.findMany({ + where: { documentId: registerDoc.id }, + orderBy: { position: 'asc' }, + select: { id: true, name: true, category: true }, + }) + : []; + + const derived = deriveRequirements({ parties, data }); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsInterestedPartyRequirement.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsInterestedPartyRequirement.count({ + where: { documentId, source: 'manual' }, + }), + createMany: ({ rows, manualCount }) => + tx.ismsInterestedPartyRequirement.createMany({ + data: rows.map((row, index) => ({ + documentId, + interestedPartyId: row.interestedPartyId, + partyName: row.partyName, + requirement: row.requirement, + treatment: row.treatment, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +async function generateObjectives({ + tx, + documentId, + data, +}: { + tx: Tx; + documentId: string; + data: IsmsPlatformData; +}): Promise { + const derived = deriveObjectives(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsObjective.deleteMany({ where: { documentId, source: 'derived' } }), + countManual: () => + tx.ismsObjective.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsObjective.createMany({ + data: rows.map((row, index) => ({ + documentId, + objective: row.objective, + target: row.target, + cadence: row.cadence, + plan: row.plan, + measurementMethod: row.measurementMethod, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +/** True when a stored narrative actually holds content (not null/undefined or {}). */ +function hasNarrativeContent(narrative: unknown): boolean { + return ( + narrative != null && + typeof narrative === 'object' && + !Array.isArray(narrative) && + Object.keys(narrative).length > 0 + ); +} + +async function generateNarrative({ + tx, + documentId, + type, + data, +}: { + tx: Tx; + documentId: string; + type: IsmsDocumentType; + data: IsmsPlatformData; +}): Promise { + const derived = deriveNarrativeForType({ type, data }); + if (!derived) return; + const narrative: Prisma.InputJsonValue = JSON.parse(JSON.stringify(derived)); + + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + if (latest) { + // Preserve a non-empty narrative so a regenerate never clobbers the + // customer's manual edits (CS-437 override). An absent or empty ({}) value — + // e.g. a snapshot-only version — is still seeded with the derived narrative. + if (hasNarrativeContent(latest.narrative)) return; + await tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { narrative }, + }); + return; + } + await tx.ismsDocumentVersion.create({ + data: { documentId, version: 1, isLatest: true, narrative }, + }); +} + +/** Run the type-specific derivation inside an open transaction. */ +export async function runDerivation({ + tx, + type, + documentId, + organizationId, + frameworkId, + data, +}: { + tx: Tx; + type: IsmsDocumentType; + documentId: string; + organizationId: string; + frameworkId: string; + data: IsmsPlatformData; +}): Promise { + if (type === 'context_of_organization') { + const derived = deriveContextOfOrganization(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsContextIssue.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsContextIssue.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsContextIssue.createMany({ + data: rows.map((row, index) => ({ + documentId, + kind: row.kind, + category: row.category, + description: row.description, + effect: row.effect, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); + return; + } + if (type === 'interested_parties_register') { + await generateInterestedParties({ tx, documentId, data }); + return; + } + if (type === 'interested_parties_requirements') { + await generateRequirements({ + tx, + documentId, + organizationId, + frameworkId, + data, + }); + return; + } + if (type === 'objectives_plan') { + await generateObjectives({ tx, documentId, data }); + return; + } + if (isNarrativeType(type)) { + await generateNarrative({ tx, documentId, type, data }); + return; + } + throw new BadRequestException(`Generation not implemented for type ${type}`); +} diff --git a/apps/api/src/isms/documents/interested-parties.spec.ts b/apps/api/src/isms/documents/interested-parties.spec.ts new file mode 100644 index 0000000000..aa46fea46a --- /dev/null +++ b/apps/api/src/isms/documents/interested-parties.spec.ts @@ -0,0 +1,144 @@ +import { + buildInterestedPartiesSections, + deriveInterestedParties, +} from './interested-parties'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001', 'GDPR'], + vendorCount: 4, + subProcessorCount: 2, + vendorsByCategory: { cloud: 2, software_as_a_service: 2 }, + subProcessorNames: ['Sub A', 'Sub B'], + infraVendorNames: ['Cloud A'], + memberCount: 10, + membersByDepartment: { it: 6, hr: 4 }, + deviceCount: 8, + riskCount: 3, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveInterestedParties', () => { + it('produces a lean derived set with provenance', () => { + const rows = deriveInterestedParties(data); + expect(rows.length).toBeGreaterThanOrEqual(5); + expect(rows.length).toBeLessThanOrEqual(8); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.derivedFrom.length > 0)).toBe(true); + expect(rows.every((r) => r.needsExpectations.length > 0)).toBe(true); + }); + + it('emits one regulator party per active framework', () => { + const rows = deriveInterestedParties(data); + const frameworkRows = rows.filter((r) => + r.derivedFrom.startsWith('framework:'), + ); + expect(frameworkRows.map((r) => r.derivedFrom)).toEqual([ + 'framework:ISO 27001', + 'framework:GDPR', + ]); + }); + + it('includes members, customers, vendors and sub-processors', () => { + const provenance = deriveInterestedParties(data).map((r) => r.derivedFrom); + expect(provenance).toEqual( + expect.arrayContaining([ + 'members', + 'customers', + 'vendors', + 'subprocessors', + ]), + ); + }); + + it('assigns sequential positions and is deterministic', () => { + const rows = deriveInterestedParties(data); + rows.forEach((row, index) => expect(row.position).toBe(index)); + expect(deriveInterestedParties(data)).toEqual(rows); + }); + + it('omits sub-processor row when none exist', () => { + const rows = deriveInterestedParties({ ...data, subProcessorCount: 0 }); + expect(rows.some((r) => r.derivedFrom === 'subprocessors')).toBe(false); + }); +}); + +describe('deriveInterestedParties — wizard answers (CS-438)', () => { + it('adds an insurer party when insurance.has', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { insurance: { has: true, insurerName: 'Acme Cyber' } }, + }); + const insurer = rows.find((r) => r.derivedFrom === 'wizard:insurance'); + expect(insurer).toBeDefined(); + expect(insurer?.name).toContain('Acme Cyber'); + expect(insurer?.category).toBe('Insurer'); + }); + + it('omits insurer party when insurance.has is false', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { insurance: { has: false, insurerName: '' } }, + }); + expect(rows.some((r) => r.derivedFrom === 'wizard:insurance')).toBe(false); + }); + + it('does NOT add sector regulators as parties (they are 4.2c requirement rows only)', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { sectorRegulators: ['FINMA', 'custom:My Regulator'] }, + }); + // Sector regulators are surfaced once, as requirement rows in 4.2c, never as + // duplicate parties here. + expect(rows.some((r) => r.derivedFrom === 'wizard:regulator')).toBe(false); + }); + + it('adds a Contractors workforce party when hasContractors', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { hasContractors: true }, + }); + const contractors = rows.find( + (r) => r.derivedFrom === 'wizard:contractors', + ); + expect(contractors?.name).toBe('Contractors'); + expect(contractors?.category).toBe('Workforce'); + }); + + it('adds an EU-representative party only when status is appointed', () => { + const appointed = deriveInterestedParties({ + ...data, + wizardAnswers: { euRep: { status: 'appointed', name: 'EU Rep Ltd' } }, + }); + expect( + appointed.find((r) => r.derivedFrom === 'wizard:eu_rep')?.name, + ).toContain('EU Rep Ltd'); + + const pending = deriveInterestedParties({ + ...data, + wizardAnswers: { euRep: { status: 'pending', name: '' } }, + }); + expect(pending.some((r) => r.derivedFrom === 'wizard:eu_rep')).toBe(false); + }); +}); + +describe('buildInterestedPartiesSections', () => { + it('renders a table of parties', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [ + { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + ], + requirements: [], + objectives: [], + narrative: null, + }; + const sections = buildInterestedPartiesSections(input); + expect(sections).toHaveLength(1); + expect(sections[0].table?.rows).toEqual([['Customers', 'Customer', 'n']]); + }); +}); diff --git a/apps/api/src/isms/documents/interested-parties.ts b/apps/api/src/isms/documents/interested-parties.ts new file mode 100644 index 0000000000..bf833d01ef --- /dev/null +++ b/apps/api/src/isms/documents/interested-parties.ts @@ -0,0 +1,190 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DerivedInterestedParty, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** + * Map an active framework to the regulator / certification body that is an + * interested party for that framework. Falls back to a generic cert body. + */ +function regulatorForFramework(name: string): { + name: string; + needs: string; +} { + const lower = name.toLowerCase(); + if (lower.includes('iso 27001') || lower.includes('iso27001')) { + return { + name: `Certification body (${name})`, + needs: + 'Evidence that the ISMS conforms to the standard and is operated effectively, sufficient to grant and maintain certification.', + }; + } + if (lower.includes('gdpr')) { + return { + name: 'Data protection authority (GDPR)', + needs: + 'Lawful processing of personal data, timely breach notification and demonstrable data-protection accountability.', + }; + } + if (lower.includes('hipaa')) { + return { + name: 'Regulator (HIPAA)', + needs: + 'Safeguards for protected health information and adherence to the Security and Privacy Rules.', + }; + } + if (lower.includes('soc 2') || lower.includes('soc2')) { + return { + name: `Independent auditor (${name})`, + needs: + 'Evidence that the trust-services criteria are met across the audit period.', + }; + } + return { + name: `Regulator / auditor (${name})`, + needs: `Conformance with the obligations arising from ${name}.`, + }; +} + +/** + * Derive a lean set of interested parties (~5-8) from platform data. Deterministic + * so drift is a pure snapshot comparison. Manual rows are preserved by the caller. + */ +export function deriveInterestedParties( + data: IsmsPlatformData, +): DerivedInterestedParty[] { + const rows: Array> = []; + + if (data.memberCount > 0) { + rows.push({ + name: 'Employees / workforce', + category: 'Employee', + needsExpectations: + 'A safe, well-governed working environment, clear security policies, training, and protection of their personal data.', + source: 'derived', + derivedFrom: 'members', + }); + } + + rows.push({ + name: 'Customers', + category: 'Customer', + needsExpectations: + 'Confidentiality, integrity and availability of their data, contractual and regulatory compliance, and timely incident notification.', + source: 'derived', + derivedFrom: 'customers', + }); + + for (const framework of data.frameworkNames) { + const regulator = regulatorForFramework(framework); + rows.push({ + name: regulator.name, + category: 'Regulator / Certification body', + needsExpectations: regulator.needs, + source: 'derived', + derivedFrom: `framework:${framework}`, + }); + } + + if (data.vendorCount > 0) { + rows.push({ + name: 'Suppliers & service providers', + category: 'Supplier', + needsExpectations: + 'Clear security requirements, prompt cooperation on assessments, and adherence to contractual obligations.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (data.subProcessorCount > 0) { + rows.push({ + name: 'Sub-processors', + category: 'Supplier', + needsExpectations: + 'Defined data-processing instructions, breach-notification terms and protection of data handled on the organization’s behalf.', + source: 'derived', + derivedFrom: 'subprocessors', + }); + } + + rows.push(...wizardDerivedParties(data)); + + return rows.map((row, index) => ({ ...row, position: index })); +} + +/** + * Interested parties contributed by the ISMS wizard answers (CS-438): insurer, + * contractors workforce and an appointed EU representative. + * + * Sector regulators are intentionally NOT added here. They are surfaced once, as + * requirement rows in the 4.2c Requirements document (wizardRegulatorRequirements + * in requirements.ts); adding them as parties too would double-list each + * regulator's obligation across 4.2b and 4.2c. + */ +function wizardDerivedParties( + data: IsmsPlatformData, +): Array> { + const answers = data.wizardAnswers; + const rows: Array> = []; + + if (answers.insurance?.has) { + const insurer = answers.insurance.insurerName?.trim(); + rows.push({ + name: insurer ? `Insurer (${insurer})` : 'Insurer', + category: 'Insurer', + needsExpectations: + 'Demonstrable risk management, prompt incident notification and evidence of effective security controls to support coverage.', + source: 'derived', + derivedFrom: 'wizard:insurance', + }); + } + + if (answers.hasContractors) { + rows.push({ + name: 'Contractors', + category: 'Workforce', + needsExpectations: + 'Clear acceptable-use rules, scoped access aligned to their engagement, and protection of any data they handle.', + source: 'derived', + derivedFrom: 'wizard:contractors', + }); + } + + if (answers.euRep?.status === 'appointed') { + const repName = answers.euRep.name?.trim(); + rows.push({ + name: repName + ? `EU representative (${repName})` + : 'EU representative (Art. 27 GDPR)', + category: 'Regulator', + needsExpectations: + 'Acts as the local point of contact for EU data-protection authorities and data subjects on behalf of the organization.', + source: 'derived', + derivedFrom: 'wizard:eu_rep', + }); + } + + return rows; +} + +export function buildInterestedPartiesSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Interested Parties', + emptyText: 'No interested parties recorded.', + table: { + headers: ['Interested party', 'Category', 'Needs & expectations'], + rows: input.interestedParties.map((party) => [ + party.name, + party.category, + party.needsExpectations, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/leadership.ts b/apps/api/src/isms/documents/leadership.ts new file mode 100644 index 0000000000..21fa0fc55c --- /dev/null +++ b/apps/api/src/isms/documents/leadership.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Narrative shape persisted in IsmsDocumentVersion.narrative for leadership_commitment (5.1). */ +export const leadershipNarrativeSchema = z.object({ + statement: z.string(), + commitments: z.array( + z.object({ + key: z.string(), + text: z.string(), + }), + ), +}); + +export type LeadershipNarrative = z.infer; + +/** + * The ISO 27001:2022 clause 5.1(a)-(h) leadership commitments, parameterized with + * the organization name. Deterministic boilerplate. + */ +function commitmentsFor( + organizationName: string, +): LeadershipNarrative['commitments'] { + return [ + { + key: 'a', + text: 'Ensuring the information security policy and information security objectives are established and are compatible with the strategic direction of the organization.', + }, + { + key: 'b', + text: 'Ensuring the integration of the information security management system requirements into the organization’s processes.', + }, + { + key: 'c', + text: 'Ensuring that the resources needed for the information security management system are available.', + }, + { + key: 'd', + text: 'Communicating the importance of effective information security management and of conforming to the information security management system requirements.', + }, + { + key: 'e', + text: 'Ensuring that the information security management system achieves its intended outcome(s).', + }, + { + key: 'f', + text: 'Directing and supporting persons to contribute to the effectiveness of the information security management system.', + }, + { + key: 'g', + text: 'Promoting continual improvement of the information security management system.', + }, + { + key: 'h', + text: `Supporting other relevant management roles within ${organizationName} to demonstrate their leadership as it applies to their areas of responsibility.`, + }, + ]; +} + +/** + * Derive the default leadership-and-commitment statement (5.1). Sign-off uses the + * existing document approver flow. + */ +export function deriveLeadershipNarrative( + data: IsmsPlatformData, +): LeadershipNarrative { + const commitments = commitmentsFor(data.organizationName); + const deputy = deputySpoCommitment(data); + if (deputy) commitments.push(deputy); + + return { + statement: `Top management of ${data.organizationName} is committed to the information security management system (ISMS) and demonstrates leadership and commitment with respect to the ISMS by:`, + commitments, + }; +} + +/** + * Reference the Deputy Security & Privacy Officer (wizard answer) so leadership + * resourcing reflects the appointed deputy or the intent to name one. + */ +function deputySpoCommitment( + data: IsmsPlatformData, +): LeadershipNarrative['commitments'][number] | null { + const deputy = data.wizardAnswers.deputySpo; + if (!deputy) return null; + + if (deputy.toBeNamed) { + return { + key: 'i', + text: 'Committing to appoint a Deputy Security & Privacy Officer to support the SPO and ensure continuity of ISMS leadership.', + }; + } + if (deputy.memberId) { + return { + key: 'i', + text: 'Appointing a Deputy Security & Privacy Officer to support the SPO and ensure continuity of ISMS leadership.', + }; + } + return null; +} + +export function buildLeadershipSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const parsed = leadershipNarrativeSchema.safeParse(input.narrative); + if (!parsed.success) { + return [ + { + heading: 'Leadership and Commitment', + emptyText: 'No leadership statement saved.', + }, + ]; + } + const narrative = parsed.data; + + return [ + { + heading: 'Leadership and Commitment', + paragraphs: [ + { text: narrative.statement }, + ...narrative.commitments.map((commitment) => ({ + label: `(${commitment.key}) `, + text: commitment.text, + })), + ], + }, + ]; +} diff --git a/apps/api/src/isms/documents/narrative.spec.ts b/apps/api/src/isms/documents/narrative.spec.ts new file mode 100644 index 0000000000..bfafca0dcc --- /dev/null +++ b/apps/api/src/isms/documents/narrative.spec.ts @@ -0,0 +1,177 @@ +import { + buildScopeSections, + deriveScopeNarrative, + ismsScopeNarrativeSchema, +} from './scope'; +import { + buildLeadershipSections, + deriveLeadershipNarrative, + leadershipNarrativeSchema, +} from './leadership'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme Inc', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const emptyInput = (narrative: unknown): DocumentExportInput => ({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative, +}); + +describe('scope narrative (4.3)', () => { + it('derives a schema-valid narrative referencing org + frameworks', () => { + const narrative = deriveScopeNarrative(data); + expect(ismsScopeNarrativeSchema.safeParse(narrative).success).toBe(true); + expect(narrative.certificateScopeSentence).toContain('Acme Inc'); + expect(narrative.certificateScopeSentence).toContain('ISO 27001'); + expect(narrative.dependencies).toEqual( + expect.arrayContaining([ + 'Cloud A (cloud / infrastructure provider).', + 'Sub A (sub-processor).', + ]), + ); + }); + + it('renders scope sections from a saved narrative', () => { + const sections = buildScopeSections(emptyInput(deriveScopeNarrative(data))); + const headings = sections.map((s) => s.heading); + expect(headings).toEqual( + expect.arrayContaining([ + 'Scope statement', + 'Interfaces', + 'Dependencies', + 'Exclusions', + ]), + ); + }); + + it('renders a placeholder when narrative is invalid', () => { + const sections = buildScopeSections(emptyInput({ bogus: true })); + expect(sections[0].emptyText).toBeDefined(); + }); + + it('collapses stray whitespace so the scope sentence has no double spaces (CS-437)', () => { + const narrative = deriveScopeNarrative({ + ...data, + organizationName: 'Comp AI ', + }); + expect(narrative.certificateScopeSentence).not.toMatch(/ {2,}/); + expect(narrative.certificateScopeSentence).toContain('of Comp AI covers'); + }); + + it('normalizes a wizard-confirmed sentence that contains double spaces (CS-437)', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + certificateScopeSentence: 'The ISMS covers everything.', + }, + }); + expect(narrative.certificateScopeSentence).toBe( + 'The ISMS covers everything.', + ); + }); + + it('uses the wizard certificate scope sentence when set (CS-438)', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + certificateScopeSentence: 'A bespoke, customer-confirmed scope.', + }, + }); + expect(narrative.certificateScopeSentence).toBe( + 'A bespoke, customer-confirmed scope.', + ); + }); + + it('threads cloudScopeSplit into interfaces/dependencies and capabilities into in-scope', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + capabilitiesInProduction: ['Payments API', 'Reporting'], + cloudScopeSplit: { + customer: ['Data', 'Databases'], + provider: ['Underlying infrastructure'], + }, + }, + }); + expect(narrative.inScope).toContain('Payments API'); + expect(narrative.interfaces).toEqual( + expect.arrayContaining([ + 'Underlying infrastructure — managed by the cloud provider.', + ]), + ); + expect(narrative.dependencies).toEqual( + expect.arrayContaining([ + 'Data — managed by the organization.', + 'Databases — managed by the organization.', + ]), + ); + }); +}); + +describe('leadership narrative (5.1)', () => { + it('derives all eight (a)-(h) commitments', () => { + const narrative = deriveLeadershipNarrative(data); + expect(leadershipNarrativeSchema.safeParse(narrative).success).toBe(true); + expect(narrative.commitments.map((c) => c.key)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + ]); + expect(narrative.statement).toContain('Acme Inc'); + }); + + it('renders leadership sections with labelled commitments', () => { + const sections = buildLeadershipSections( + emptyInput(deriveLeadershipNarrative(data)), + ); + expect(sections[0].heading).toBe('Leadership and Commitment'); + expect(sections[0].paragraphs?.some((p) => p.label === '(a) ')).toBe(true); + }); + + it('references the Deputy SPO when appointed (CS-438)', () => { + const narrative = deriveLeadershipNarrative({ + ...data, + wizardAnswers: { deputySpo: { memberId: 'mem_1', toBeNamed: false } }, + }); + const deputy = narrative.commitments.find((c) => c.key === 'i'); + expect(deputy?.text).toContain('Deputy Security & Privacy Officer'); + }); + + it('references the intent to name a Deputy SPO when toBeNamed', () => { + const narrative = deriveLeadershipNarrative({ + ...data, + wizardAnswers: { deputySpo: { memberId: null, toBeNamed: true } }, + }); + const deputy = narrative.commitments.find((c) => c.key === 'i'); + expect(deputy?.text).toContain('appoint a Deputy Security & Privacy Officer'); + }); + + it('omits the Deputy SPO commitment when not provided', () => { + const narrative = deriveLeadershipNarrative(data); + expect(narrative.commitments.some((c) => c.key === 'i')).toBe(false); + }); +}); diff --git a/apps/api/src/isms/documents/objectives.spec.ts b/apps/api/src/isms/documents/objectives.spec.ts new file mode 100644 index 0000000000..9babd6ec37 --- /dev/null +++ b/apps/api/src/isms/documents/objectives.spec.ts @@ -0,0 +1,161 @@ +import { buildObjectivesSections, deriveObjectives } from './objectives'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 4, + highRiskCount: 2, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveObjectives', () => { + it('derives framework, training, risk and vendor objectives', () => { + const rows = deriveObjectives(data); + const provenance = rows.map((r) => r.derivedFrom); + expect(provenance).toEqual( + expect.arrayContaining([ + 'framework:ISO 27001', + 'training', + 'risks', + 'vendors', + ]), + ); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.objective.length > 0)).toBe(true); + }); + + it('references the high/critical risk count in the risk objective target', () => { + const rows = deriveObjectives(data); + const riskRow = rows.find((r) => r.derivedFrom === 'risks'); + expect(riskRow?.target).toContain('2'); + }); + + it('omits the risk objective when there are no risks', () => { + const rows = deriveObjectives({ ...data, riskCount: 0, highRiskCount: 0 }); + expect(rows.some((r) => r.derivedFrom === 'risks')).toBe(false); + }); + + it('assigns sequential positions and is deterministic', () => { + const rows = deriveObjectives(data); + rows.forEach((row, index) => expect(row.position).toBe(index)); + expect(deriveObjectives(data)).toEqual(rows); + }); + + it('uses wizard objectives when provided, overriding defaults (CS-438)', () => { + const rows = deriveObjectives({ + ...data, + wizardAnswers: { + objectives: [ + { objective: 'Reduce phishing click rate', target: '< 3%' }, + { objective: 'Patch criticals in 7 days', target: '100%' }, + ], + }, + }); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.objective)).toEqual([ + 'Reduce phishing click rate', + 'Patch criticals in 7 days', + ]); + expect(rows[0].target).toBe('< 3%'); + expect(rows.every((r) => r.derivedFrom === 'wizard:objective')).toBe(true); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + }); + + it('falls back to defaults when wizard objectives was never set', () => { + const rows = deriveObjectives({ ...data, wizardAnswers: {} }); + expect(rows.some((r) => r.derivedFrom === 'wizard:objective')).toBe(false); + expect(rows.some((r) => r.derivedFrom.startsWith('framework:'))).toBe(true); + }); + + it('respects an explicitly-saved empty objectives array (CS-438)', () => { + const rows = deriveObjectives({ ...data, wizardAnswers: { objectives: [] } }); + expect(rows).toEqual([]); + }); + + it('respects an explicitly-saved array that is empty after dropping blank rows', () => { + const rows = deriveObjectives({ + ...data, + wizardAnswers: { + objectives: [ + { objective: ' ', target: 'ignored' }, + { objective: '', target: '' }, + ], + }, + }); + expect(rows).toEqual([]); + }); +}); + +describe('buildObjectivesSections', () => { + it('renders an objectives table including plan, measurement, cadence and status', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [ + { + objective: 'Maintain ISO 27001', + target: 'Certified', + cadence: 'Annual', + status: 'on_track', + plan: 'Operate controls and pass the audit', + measurementMethod: 'Audit outcome', + }, + ], + narrative: null, + }; + const sections = buildObjectivesSections(input); + expect(sections[0].table?.headers).toEqual([ + 'Objective', + 'Target', + 'Plan', + 'Measurement', + 'Cadence', + 'Status', + ]); + expect(sections[0].table?.rows).toEqual([ + [ + 'Maintain ISO 27001', + 'Certified', + 'Operate controls and pass the audit', + 'Audit outcome', + 'Annual', + 'on_track', + ], + ]); + }); + + it('renders em-dashes for missing plan and measurement', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [ + { + objective: 'Reduce phishing click rate', + target: '< 3%', + cadence: null, + status: 'on_track', + plan: null, + measurementMethod: null, + }, + ], + narrative: null, + }; + const sections = buildObjectivesSections(input); + expect(sections[0].table?.rows).toEqual([ + ['Reduce phishing click rate', '< 3%', '—', '—', '—', 'on_track'], + ]); + }); +}); diff --git a/apps/api/src/isms/documents/objectives.ts b/apps/api/src/isms/documents/objectives.ts new file mode 100644 index 0000000000..5e8939eda2 --- /dev/null +++ b/apps/api/src/isms/documents/objectives.ts @@ -0,0 +1,119 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DerivedObjective, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** + * Derive a small set of default information-security objectives (6.2) from active + * frameworks, the risk register and the training programme. Deterministic so drift + * is a pure snapshot comparison. Manual rows are preserved by the caller. + */ +export function deriveObjectives(data: IsmsPlatformData): DerivedObjective[] { + // An explicitly-saved objectives array is the user's choice and must be + // respected — even when it ends up empty after dropping blank-text rows. + // Only fall through to the standard derived objectives when the field was + // never set (undefined). Mirrors buildWizardDefaults, which preserves a saved + // empty array rather than reseeding it from defaults. + const savedObjectives = data.wizardAnswers.objectives; + if (savedObjectives !== undefined) { + return savedObjectives + .filter((objective) => objective.objective?.trim()) + .map((objective, index) => ({ + objective: objective.objective, + target: objective.target || null, + cadence: null, + plan: null, + measurementMethod: null, + source: 'derived', + derivedFrom: 'wizard:objective', + position: index, + })); + } + + const rows: Array> = []; + + for (const framework of data.frameworkNames) { + rows.push({ + objective: `Maintain ${framework} compliance`, + target: `Certified / conformant with ${framework}`, + cadence: 'Annual', + plan: `Operate the ISMS controls, complete internal audits and management reviews, and pass the ${framework} audit.`, + measurementMethod: 'Audit outcome and number of non-conformities.', + source: 'derived', + derivedFrom: `framework:${framework}`, + }); + } + + if (data.hasTrainingProgram || data.memberCount > 0) { + rows.push({ + objective: 'Achieve high security-awareness training completion', + target: 'Training completion ≥ 95%', + cadence: 'Quarterly', + plan: 'Assign annual security-awareness training to all staff and track completion through the platform.', + measurementMethod: 'Percentage of staff who completed assigned training.', + source: 'derived', + derivedFrom: 'training', + }); + } + + if (data.riskCount > 0) { + rows.push({ + objective: 'Resolve high and critical risks within SLA', + target: + data.highRiskCount > 0 + ? `Remediate ${data.highRiskCount} high/critical risk${data.highRiskCount === 1 ? '' : 's'} within SLA` + : 'No high/critical risks open beyond SLA', + cadence: 'Monthly', + plan: 'Triage risks in the register, assign owners and treatment plans, and track residual scores to closure.', + measurementMethod: 'Number of high/critical risks open beyond their SLA.', + source: 'derived', + derivedFrom: 'risks', + }); + } + + if (data.vendorCount > 0) { + rows.push({ + objective: 'Complete scheduled vendor security reviews', + target: '100% of in-scope vendors reviewed on schedule', + cadence: 'Annual', + plan: 'Maintain the vendor register, run periodic assessments and follow up on findings.', + measurementMethod: + 'Percentage of vendors reviewed within the review window.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + return rows.map((row, index) => ({ ...row, position: index })); +} + +export function buildObjectivesSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Information Security Objectives', + emptyText: 'No objectives recorded.', + table: { + headers: [ + 'Objective', + 'Target', + 'Plan', + 'Measurement', + 'Cadence', + 'Status', + ], + rows: input.objectives.map((objective) => [ + objective.objective, + objective.target ?? '—', + objective.plan ?? '—', + objective.measurementMethod ?? '—', + objective.cadence ?? '—', + objective.status, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/org-profile.spec.ts b/apps/api/src/isms/documents/org-profile.spec.ts new file mode 100644 index 0000000000..ac4a8e2308 --- /dev/null +++ b/apps/api/src/isms/documents/org-profile.spec.ts @@ -0,0 +1,68 @@ +// Mock @db before importing the unit under test so loadOrgProfile reads from +// these fakes. We only stub the table methods the function actually calls. +const mockDb = { + organization: { findUnique: jest.fn() }, + context: { findMany: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { loadOrgProfile } from './org-profile'; +import { DEFAULT_INTENDED_OUTCOMES } from '../wizard/wizard-defaults'; + +const ARGS = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +function seedDb({ answers }: { answers: unknown }) { + mockDb.organization.findUnique.mockResolvedValue({ + name: 'Acme', + website: 'https://acme.test', + }); + mockDb.context.findMany.mockResolvedValue([]); + mockDb.ismsProfile.findUnique.mockResolvedValue({ answers }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('loadOrgProfile intendedOutcomes', () => { + it('falls back to the default outcomes when intended outcomes were never set', async () => { + seedDb({ answers: {} }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + }); + + it('falls back to the default outcomes when there is no saved profile', async () => { + mockDb.organization.findUnique.mockResolvedValue({ name: 'Acme', website: null }); + mockDb.context.findMany.mockResolvedValue([]); + mockDb.ismsProfile.findUnique.mockResolvedValue(null); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + }); + + it('respects an explicitly-saved empty intended-outcomes array (CS-438)', async () => { + seedDb({ answers: { intendedOutcomes: [] } }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual([]); + }); + + it('uses saved intended outcomes when provided, overriding defaults', async () => { + seedDb({ + answers: { intendedOutcomes: ['Protect customer data', 'Stay certified'] }, + }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual([ + 'Protect customer data', + 'Stay certified', + ]); + }); +}); diff --git a/apps/api/src/isms/documents/org-profile.ts b/apps/api/src/isms/documents/org-profile.ts new file mode 100644 index 0000000000..be77f5ccdb --- /dev/null +++ b/apps/api/src/isms/documents/org-profile.ts @@ -0,0 +1,89 @@ +import { db } from '@db'; +import { DEFAULT_INTENDED_OUTCOMES } from '../wizard/wizard-defaults'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsKeyValue } from '../utils/export-shared'; +import type { IsmsOrgProfile } from './types'; + +/** + * Exact onboarding question strings (apps/app/.../setup/lib/constants.ts). The + * answers are persisted verbatim into the Context Q&A table at signup; we read + * them back to populate the Context-of-the-Organization overview. Structured + * answers (C-suite, address) are stored as "[object Object]" and skipped. + */ +const QUESTIONS = { + describe: 'Describe your company in a few sentences', + industry: 'What industry is your company in?', + teamSize: 'How many employees do you have?', + workLocation: 'How does your team work?', + dataTypes: 'What types of data do you handle?', + geo: 'Where is your data located?', + infrastructure: 'Where do you host your applications and data?', +} as const; + +/** + * Assemble the narrative inputs for the Context of the Organization document: + * the overview table, the mission statement and the ISMS intended outcomes. + * Everything is best-effort — any field we cannot resolve is simply omitted so + * the document degrades gracefully rather than showing blanks. + */ +export async function loadOrgProfile({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [organization, contextEntries, profile] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, website: true }, + }), + db.context.findMany({ + where: { organizationId }, + // Deterministic ordering so duplicate questions resolve to the same answer + // every export; the earliest-created entry wins (id breaks createdAt ties). + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { question: true, answer: true }, + }), + db.ismsProfile.findUnique({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + select: { answers: true }, + }), + ]); + + // First (earliest-created) entry wins for any duplicated question. + const answers = new Map(); + for (const entry of contextEntries) { + if (!answers.has(entry.question)) { + answers.set(entry.question, (entry.answer ?? '').trim()); + } + } + const get = (question: string): string | null => { + const value = answers.get(question); + return value && value !== '[object Object]' ? value : null; + }; + + const overview: IsmsKeyValue[] = []; + const add = (label: string, value: string | null) => { + if (value) overview.push({ label, value }); + }; + add('Legal entity', organization?.name?.trim() || null); + add('Website', organization?.website?.trim() || null); + add('Industry', get(QUESTIONS.industry)); + add('Workforce', get(QUESTIONS.teamSize)); + add('Working model', get(QUESTIONS.workLocation)); + add('Data handled', get(QUESTIONS.dataTypes)); + add('Data locations', get(QUESTIONS.geo)); + add('Hosting', get(QUESTIONS.infrastructure)); + + const wizard = parseStoredAnswers(profile?.answers); + // Fall back to the defaults only when intended outcomes were never set. A + // saved (even empty) array is the user's choice and must be respected, not + // overwritten on every read. Mirrors buildWizardDefaults. + const intendedOutcomes = + wizard.intendedOutcomes === undefined + ? DEFAULT_INTENDED_OUTCOMES + : wizard.intendedOutcomes; + + return { overview, mission: get(QUESTIONS.describe), intendedOutcomes }; +} diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts new file mode 100644 index 0000000000..84a8ee8d24 --- /dev/null +++ b/apps/api/src/isms/documents/registry.ts @@ -0,0 +1,72 @@ +import type { IsmsDocumentType } from '@db'; +import type { ZodTypeAny } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import { buildContextSections } from './context'; +import { buildInterestedPartiesSections } from './interested-parties'; +import { buildRequirementsSections } from './requirements'; +import { buildObjectivesSections } from './objectives'; +import { + buildScopeSections, + deriveScopeNarrative, + ismsScopeNarrativeSchema, +} from './scope'; +import { + buildLeadershipSections, + deriveLeadershipNarrative, + leadershipNarrativeSchema, +} from './leadership'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Document types whose content is a singleton narrative stored in version.narrative. */ +const NARRATIVE_TYPES: IsmsDocumentType[] = [ + 'isms_scope', + 'leadership_commitment', +]; + +const EXPORT_SECTION_BUILDERS: Record< + IsmsDocumentType, + (input: DocumentExportInput) => IsmsExportSection[] +> = { + context_of_organization: buildContextSections, + interested_parties_register: buildInterestedPartiesSections, + interested_parties_requirements: buildRequirementsSections, + objectives_plan: buildObjectivesSections, + isms_scope: buildScopeSections, + leadership_commitment: buildLeadershipSections, +}; + +export function buildExportSections({ + type, + input, +}: { + type: IsmsDocumentType; + input: DocumentExportInput; +}): IsmsExportSection[] { + return EXPORT_SECTION_BUILDERS[type](input); +} + +/** Zod schema validating the narrative payload for each singleton document type. */ +export function narrativeSchemaForType( + type: IsmsDocumentType, +): ZodTypeAny | null { + if (type === 'isms_scope') return ismsScopeNarrativeSchema; + if (type === 'leadership_commitment') return leadershipNarrativeSchema; + return null; +} + +/** Derive the default narrative payload for a singleton document type. */ +export function deriveNarrativeForType({ + type, + data, +}: { + type: IsmsDocumentType; + data: IsmsPlatformData; +}): Record | null { + if (type === 'isms_scope') return deriveScopeNarrative(data); + if (type === 'leadership_commitment') return deriveLeadershipNarrative(data); + return null; +} + +export function isNarrativeType(type: IsmsDocumentType): boolean { + return NARRATIVE_TYPES.includes(type); +} diff --git a/apps/api/src/isms/documents/requirements.spec.ts b/apps/api/src/isms/documents/requirements.spec.ts new file mode 100644 index 0000000000..7fa1f89a61 --- /dev/null +++ b/apps/api/src/isms/documents/requirements.spec.ts @@ -0,0 +1,88 @@ +import { buildRequirementsSections, deriveRequirements } from './requirements'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 0, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveRequirements', () => { + it('derives one requirement per supplied party and links the party id', () => { + const parties = [ + { id: 'ip_1', name: 'Customers', category: 'Customer' }, + { + id: 'ip_2', + name: 'Regulator', + category: 'Regulator / Certification body', + }, + ]; + const rows = deriveRequirements({ parties, data }); + expect(rows).toHaveLength(2); + expect(rows[0].interestedPartyId).toBe('ip_1'); + expect(rows[0].derivedFrom).toBe('party:ip_1'); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.requirement.length > 0)).toBe(true); + expect(rows.every((r) => r.treatment.length > 0)).toBe(true); + }); + + it('falls back to a platform-derived default set when no parties exist', () => { + const rows = deriveRequirements({ parties: [], data }); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.interestedPartyId === null)).toBe(true); + expect(rows[0].derivedFrom.startsWith('party:')).toBe(true); + }); + + it('assigns sequential positions', () => { + const rows = deriveRequirements({ parties: [], data }); + rows.forEach((row, index) => expect(row.position).toBe(index)); + }); + + it('appends one requirement row per wizard sector regulator (CS-438)', () => { + const parties = [{ id: 'ip_1', name: 'Customers', category: 'Customer' }]; + const rows = deriveRequirements({ + parties, + data: { + ...data, + wizardAnswers: { sectorRegulators: ['FCA', 'custom:Local Authority'] }, + }, + }); + const regulatorRows = rows.filter((r) => r.derivedFrom === 'wizard:regulator'); + expect(regulatorRows).toHaveLength(2); + expect(regulatorRows.map((r) => r.partyName)).toEqual([ + 'Regulator (FCA)', + 'Regulator (Local Authority)', + ]); + // Wizard rows come after the party rows and keep sequential positions. + expect(regulatorRows[0].position).toBe(parties.length); + expect(regulatorRows.every((r) => r.interestedPartyId === null)).toBe(true); + }); +}); + +describe('buildRequirementsSections', () => { + it('renders a requirements/treatment table', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [ + { partyName: 'Customers', requirement: 'r', treatment: 't' }, + ], + objectives: [], + narrative: null, + }; + const sections = buildRequirementsSections(input); + expect(sections[0].table?.rows).toEqual([['Customers', 'r', 't']]); + }); +}); diff --git a/apps/api/src/isms/documents/requirements.ts b/apps/api/src/isms/documents/requirements.ts new file mode 100644 index 0000000000..dce2ee33d4 --- /dev/null +++ b/apps/api/src/isms/documents/requirements.ts @@ -0,0 +1,163 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import { deriveInterestedParties } from './interested-parties'; +import { formatRegulatorLabel } from './wizard-helpers'; +import type { + DerivedInterestedParty, + DerivedRequirement, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** A party row read from the org's Interested Parties Register. */ +export interface PartyInput { + id: string; + name: string; + category: string; +} + +/** + * Map a party (by category / name) to one representative requirement and the + * ISMS treatment that addresses it, referencing relevant policy/control areas + * generically. Deterministic. + */ +function requirementForParty(party: { name: string; category: string }): { + requirement: string; + treatment: string; +} { + const category = party.category.toLowerCase(); + if (category.includes('employee')) { + return { + requirement: + 'A secure workplace, clear acceptable-use rules and protection of their personal data.', + treatment: + 'Addressed by the Access Control, Acceptable Use and HR Security policies, mandatory security-awareness training, and role-based access controls.', + }; + } + if (category.includes('customer')) { + return { + requirement: + 'Confidentiality, integrity and availability of their data and timely breach notification.', + treatment: + 'Addressed by encryption, access control, logging/monitoring and the Incident Response policy with defined notification SLAs.', + }; + } + if (category.includes('regulator') || category.includes('certification')) { + return { + requirement: + 'Demonstrable conformance with the applicable standard or regulation.', + treatment: + 'Addressed by the Statement of Applicability, internal audit programme, management review and the full ISMS control set evidenced in the platform.', + }; + } + if (category.includes('supplier')) { + return { + requirement: + 'Clear security requirements and protection of any data shared with them.', + treatment: + 'Addressed by the Supplier/Vendor Management policy, vendor risk assessments, data-processing agreements and periodic vendor reviews.', + }; + } + return { + requirement: `Relevant security and compliance expectations of ${party.name}.`, + treatment: + 'Addressed by the relevant ISMS policies and controls, monitored through the risk register and management review.', + }; +} + +/** + * Derive one representative requirement + treatment per interested party. Uses the + * org's existing parties register when supplied; otherwise falls back to the + * platform-derived default party set. Manual rows are preserved by the caller. + */ +export function deriveRequirements({ + parties, + data, +}: { + parties: PartyInput[]; + data: IsmsPlatformData; +}): DerivedRequirement[] { + const source: Array<{ + interestedPartyId: string | null; + name: string; + category: string; + }> = + parties.length > 0 + ? parties.map((party) => ({ + interestedPartyId: party.id, + name: party.name, + category: party.category, + })) + : deriveInterestedParties(data).map((party: DerivedInterestedParty) => ({ + interestedPartyId: null, + name: party.name, + category: party.category, + })); + + const partyRows: DerivedRequirement[] = source.map((party, index) => { + const mapped = requirementForParty(party); + return { + partyName: party.name, + requirement: mapped.requirement, + treatment: mapped.treatment, + source: 'derived', + derivedFrom: party.interestedPartyId + ? `party:${party.interestedPartyId}` + : `party:${party.name}`, + position: index, + interestedPartyId: party.interestedPartyId, + }; + }); + + const wizardRows = wizardRegulatorRequirements({ + data, + startPosition: partyRows.length, + }); + + return [...partyRows, ...wizardRows]; +} + +/** + * One requirement + ISMS treatment row per sector regulator named in the wizard + * (CS-438). Sourced as derived with provenance `wizard:regulator`. + */ +function wizardRegulatorRequirements({ + data, + startPosition, +}: { + data: IsmsPlatformData; + startPosition: number; +}): DerivedRequirement[] { + const regulators = data.wizardAnswers.sectorRegulators ?? []; + + return regulators.map((regulator, index) => { + const label = formatRegulatorLabel(regulator); + return { + partyName: `Regulator (${label})`, + requirement: `Conformance with the sector obligations arising from ${label}.`, + treatment: `Addressed by the relevant ISMS policies and controls, the Statement of Applicability, and the compliance monitoring tracked for ${label}.`, + source: 'derived', + derivedFrom: 'wizard:regulator', + position: startPosition + index, + interestedPartyId: null, + }; + }); +} + +export function buildRequirementsSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Requirements & ISMS Treatment', + emptyText: 'No requirements recorded.', + table: { + headers: ['Interested party', 'Requirement', 'ISMS treatment'], + rows: input.requirements.map((row) => [ + row.partyName, + row.requirement, + row.treatment, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/scope.ts b/apps/api/src/isms/documents/scope.ts new file mode 100644 index 0000000000..bef67c1730 --- /dev/null +++ b/apps/api/src/isms/documents/scope.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Narrative shape persisted in IsmsDocumentVersion.narrative for isms_scope (4.3). */ +export const ismsScopeNarrativeSchema = z.object({ + certificateScopeSentence: z.string(), + inScope: z.string(), + interfaces: z.array(z.string()), + dependencies: z.array(z.string()), + exclusions: z.array(z.string()), + justification: z.string().optional(), +}); + +export type IsmsScopeNarrative = z.infer; + +/** + * Collapse runs of whitespace to a single space and trim. Interpolated values + * (org name, framework list) can carry stray/trailing whitespace, which would + * otherwise produce double spaces in the assembled sentence (CS-437). + */ +function normalizeSentence(sentence: string): string { + return sentence.replace(/\s+/g, ' ').trim(); +} + +/** + * Derive a default ISMS scope statement (4.3) from platform data. The certificate + * scope sentence is templated from the org name + active frameworks; interfaces + * come from vendors and dependencies from sub-processors + key infra vendors. + */ +export function deriveScopeNarrative( + data: IsmsPlatformData, +): IsmsScopeNarrative { + const answers = data.wizardAnswers; + const frameworks = + data.frameworkNames.length > 0 + ? data.frameworkNames.join(', ') + : 'the applicable information-security standards'; + + // Wizard-confirmed certificate scope sentence wins over the generated default. + const wizardSentence = answers.certificateScopeSentence?.trim(); + const certificateScopeSentence = normalizeSentence( + wizardSentence && wizardSentence.length > 0 + ? wizardSentence + : `The information security management system of ${data.organizationName} covers the people, processes and technology supporting the delivery and operation of its products and services, in accordance with ${frameworks}.`, + ); + + const inScope = deriveInScope(data); + const interfaces = deriveInterfaces(data); + const dependencies = deriveDependencies(data); + + return { + certificateScopeSentence, + inScope, + interfaces, + dependencies, + exclusions: [], + justification: undefined, + }; +} + +/** In-scope description: prefer the wizard's confirmed live capabilities. */ +function deriveInScope(data: IsmsPlatformData): string { + const capabilities = data.wizardAnswers.capabilitiesInProduction ?? []; + if (capabilities.length > 0) { + return `The ISMS covers the delivery and operation of the following capabilities in production: ${capabilities.join(', ')}. All supporting information assets, personnel${data.memberCount > 0 ? ` (${data.memberCount} workforce members)` : ''}, systems and cloud infrastructure are within scope.`; + } + return `All information assets, personnel${data.memberCount > 0 ? ` (${data.memberCount} workforce members)` : ''}, systems and supporting cloud infrastructure used by ${data.organizationName} to deliver its services are within the ISMS scope.`; +} + +/** Interfaces: include the provider-managed cloud layers named in the wizard. */ +function deriveInterfaces(data: IsmsPlatformData): string[] { + const interfaces: string[] = []; + if (data.vendorCount > 0) { + interfaces.push( + `Third-party suppliers and service providers (${data.vendorCount}) that interface with organizational systems and data.`, + ); + } + for (const layer of data.wizardAnswers.cloudScopeSplit?.provider ?? []) { + interfaces.push(`${layer} — managed by the cloud provider.`); + } + if (interfaces.length === 0) { + interfaces.push('No external supplier interfaces are currently recorded.'); + } + return interfaces; +} + +/** Dependencies: infra vendors, sub-processors and customer-managed cloud layers. */ +function deriveDependencies(data: IsmsPlatformData): string[] { + const dependencies: string[] = []; + for (const name of data.infraVendorNames) { + dependencies.push(`${name} (cloud / infrastructure provider).`); + } + for (const name of data.subProcessorNames) { + dependencies.push(`${name} (sub-processor).`); + } + for (const layer of data.wizardAnswers.cloudScopeSplit?.customer ?? []) { + dependencies.push(`${layer} — managed by the organization.`); + } + if (dependencies.length === 0) { + dependencies.push('No external dependencies are currently recorded.'); + } + return dependencies; +} + +function listToSection(heading: string, items: string[]): IsmsExportSection { + return { + heading, + emptyText: 'None.', + paragraphs: items.map((text) => ({ text })), + }; +} + +export function buildScopeSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const parsed = ismsScopeNarrativeSchema.safeParse(input.narrative); + if (!parsed.success) { + return [{ heading: 'ISMS Scope', emptyText: 'No scope statement saved.' }]; + } + const narrative = parsed.data; + + const sections: IsmsExportSection[] = [ + { + heading: 'Scope statement', + paragraphs: [ + { text: narrative.certificateScopeSentence }, + { label: 'In scope: ', text: narrative.inScope }, + ], + }, + listToSection('Interfaces', narrative.interfaces), + listToSection('Dependencies', narrative.dependencies), + listToSection('Exclusions', narrative.exclusions), + ]; + + if (narrative.justification) { + sections.push({ + heading: 'Justification for exclusions', + paragraphs: [{ text: narrative.justification }], + }); + } + + return sections; +} diff --git a/apps/api/src/isms/documents/snapshot.spec.ts b/apps/api/src/isms/documents/snapshot.spec.ts new file mode 100644 index 0000000000..d0c52e4b28 --- /dev/null +++ b/apps/api/src/isms/documents/snapshot.spec.ts @@ -0,0 +1,218 @@ +import { diffPlatformSnapshots, parsePlatformSnapshot } from './snapshot'; +import type { IsmsPlatformData } from './types'; + +const base: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: 'fp-base', +}; + +describe('diffPlatformSnapshots', () => { + it('flags no-baseline when previous is null', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: null, + current: base, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toEqual(['no-baseline']); + }); + + it('reports not stale when nothing relevant changed', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: base, + }); + expect(result.isStale).toBe(false); + expect(result.changedSources).toHaveLength(0); + }); + + it('only reports sources the objectives doc derives from (risk drift)', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: { ...base, riskCount: 9, deviceCount: 99 }, + }); + expect(result.changedSources).toContain('risks'); + // objectives_plan does not derive from devices. + expect(result.changedSources).not.toContain('devices'); + }); + + it('detects framework drift regardless of order', () => { + const same = diffPlatformSnapshots({ + type: 'isms_scope', + previous: { ...base, frameworkNames: ['ISO 27001', 'SOC 2'] }, + current: { ...base, frameworkNames: ['SOC 2', 'ISO 27001'] }, + }); + expect(same.isStale).toBe(false); + }); + + it('detects vendor category mix drift for context (same total, different mix)', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { ...base, vendorsByCategory: { cloud: 3 } }, + current: { ...base, vendorsByCategory: { cloud: 1, hr: 2 } }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('vendorMix'); + // The total count is unchanged, so the plain vendor source is not flagged. + expect(result.changedSources).not.toContain('vendors'); + }); + + it('detects department mix drift for context (same headcount, different mix)', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { ...base, membersByDepartment: { it: 5 } }, + current: { ...base, membersByDepartment: { it: 2, hr: 3 } }, + }); + expect(result.changedSources).toContain('departmentMix'); + expect(result.changedSources).not.toContain('members'); + }); + + it('ignores vendor/department mix key order', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { + ...base, + vendorsByCategory: { cloud: 2, hr: 1 }, + membersByDepartment: { it: 3, hr: 2 }, + }, + current: { + ...base, + vendorsByCategory: { hr: 1, cloud: 2 }, + membersByDepartment: { hr: 2, it: 3 }, + }, + }); + expect(result.isStale).toBe(false); + }); + + it('detects member-count drift for objectives (6.2 uses memberCount)', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: { ...base, memberCount: 50 }, + }); + expect(result.changedSources).toContain('members'); + }); + + it('detects wizard-answer drift for documents that derive from them', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_register', + previous: { ...base, wizardAnswers: { hasContractors: false } }, + current: { ...base, wizardAnswers: { hasContractors: true } }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('wizardAnswers'); + }); + + it('ignores wizard-answer key order', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: { + ...base, + wizardAnswers: { hasContractors: true, certificationBody: 'BSI' }, + }, + current: { + ...base, + wizardAnswers: { certificationBody: 'BSI', hasContractors: true }, + }, + }); + expect(result.isStale).toBe(false); + }); + + it('flags wizard drift for scope and leadership (both derive from wizard answers)', () => { + for (const type of ['isms_scope', 'leadership_commitment'] as const) { + const result = diffPlatformSnapshots({ + type, + previous: { ...base, wizardAnswers: { hasContractors: false } }, + current: { ...base, wizardAnswers: { hasContractors: true } }, + }); + expect(result.changedSources).toContain('wizardAnswers'); + } + }); + + it('flags requirements drift when the parties register fingerprint changes', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_requirements', + previous: base, + current: { ...base, partiesFingerprint: 'fp-edited' }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('parties'); + }); + + it('does not flag requirements drift when the parties fingerprint is unchanged', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_requirements', + previous: base, + current: base, + }); + expect(result.isStale).toBe(false); + expect(result.changedSources).not.toContain('parties'); + }); + + it('only the requirements doc treats parties edits as drift', () => { + for (const type of [ + 'context_of_organization', + 'interested_parties_register', + 'objectives_plan', + 'isms_scope', + 'leadership_commitment', + ] as const) { + const result = diffPlatformSnapshots({ + type, + previous: base, + current: { ...base, partiesFingerprint: 'fp-edited' }, + }); + expect(result.changedSources).not.toContain('parties'); + } + }); + + it('leadership only drifts on organization name', () => { + const noChange = diffPlatformSnapshots({ + type: 'leadership_commitment', + previous: base, + current: { ...base, vendorCount: 99 }, + }); + expect(noChange.isStale).toBe(false); + + const renamed = diffPlatformSnapshots({ + type: 'leadership_commitment', + previous: base, + current: { ...base, organizationName: 'NewCo' }, + }); + expect(renamed.changedSources).toContain('organizationName'); + }); +}); + +describe('parsePlatformSnapshot', () => { + it('round-trips a serialized snapshot', () => { + const parsed = parsePlatformSnapshot(JSON.parse(JSON.stringify(base))); + expect(parsed).toEqual(base); + }); + + it('returns null for non-object / missing frameworkNames', () => { + expect(parsePlatformSnapshot(null)).toBeNull(); + expect(parsePlatformSnapshot([1, 2])).toBeNull(); + expect(parsePlatformSnapshot({ foo: 'bar' })).toBeNull(); + }); + + it('defaults partiesFingerprint to empty for legacy snapshots without it', () => { + const { partiesFingerprint: _omit, ...legacy } = base; + const parsed = parsePlatformSnapshot(JSON.parse(JSON.stringify(legacy))); + expect(parsed?.partiesFingerprint).toBe(''); + }); +}); diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts new file mode 100644 index 0000000000..d37dfec93c --- /dev/null +++ b/apps/api/src/isms/documents/snapshot.ts @@ -0,0 +1,222 @@ +import type { IsmsDocumentType, Prisma } from '@db'; +import type { PartialWizardAnswers } from '../wizard/wizard-schema'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsPlatformData } from './types'; + +function sameStringSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((item) => setB.has(item)); +} + +/** Order-insensitive comparison of count-by-key maps (vendor/department mix). */ +function sameNumberRecord( + a: Record, + b: Record, +): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if ((a[key] ?? 0) !== (b[key] ?? 0)) return false; + } + return true; +} + +/** + * Deep, key-order-independent comparison of the wizard answers blob. Several + * documents derive rows from these un-derivable inputs, so any edit is drift. + */ +function sameWizardAnswers( + a: PartialWizardAnswers, + b: PartialWizardAnswers, +): boolean { + return stableStringify(a) === stableStringify(b); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`); + return `{${entries.join(',')}}`; +} + +/** + * Drift sources each document type derives from. Used to scope the diff so a + * document is only flagged stale when an input it actually consumes changes. + * Context (4.1) reads vendorsByCategory + membersByDepartment; objectives (6.2) + * reads memberCount; several docs read the un-derivable wizard answers. + */ +const TYPE_DRIFT_SOURCES: Record> = { + context_of_organization: [ + 'frameworks', + 'vendors', + 'vendorMix', + 'subprocessors', + 'members', + 'departmentMix', + 'devices', + 'wizardAnswers', + ], + interested_parties_register: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + ], + interested_parties_requirements: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + // Requirements derive one row per Interested Parties Register party, so a + // manual edit to a party (which the rest of this snapshot can't see) is drift. + 'parties', + ], + objectives_plan: [ + 'frameworks', + 'vendors', + 'risks', + 'training', + 'members', + 'wizardAnswers', + ], + isms_scope: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + ], + leadership_commitment: ['organizationName', 'wizardAnswers'], +}; + +interface DiffMap { + frameworks: boolean; + vendors: boolean; + vendorMix: boolean; + subprocessors: boolean; + members: boolean; + departmentMix: boolean; + devices: boolean; + risks: boolean; + training: boolean; + organizationName: boolean; + wizardAnswers: boolean; + parties: boolean; +} + +function computeChanges({ + previous, + current, +}: { + previous: IsmsPlatformData; + current: IsmsPlatformData; +}): DiffMap { + return { + frameworks: !sameStringSet(previous.frameworkNames, current.frameworkNames), + vendors: previous.vendorCount !== current.vendorCount, + vendorMix: !sameNumberRecord( + previous.vendorsByCategory, + current.vendorsByCategory, + ), + subprocessors: previous.subProcessorCount !== current.subProcessorCount, + members: previous.memberCount !== current.memberCount, + departmentMix: !sameNumberRecord( + previous.membersByDepartment, + current.membersByDepartment, + ), + devices: previous.deviceCount !== current.deviceCount, + risks: + previous.riskCount !== current.riskCount || + previous.highRiskCount !== current.highRiskCount, + training: previous.hasTrainingProgram !== current.hasTrainingProgram, + organizationName: previous.organizationName !== current.organizationName, + wizardAnswers: !sameWizardAnswers( + previous.wizardAnswers, + current.wizardAnswers, + ), + parties: previous.partiesFingerprint !== current.partiesFingerprint, + }; +} + +/** + * Compare two platform snapshots for a given document type, reporting only the + * sources that document derives from. A missing baseline is always stale. + */ +export function diffPlatformSnapshots({ + type, + previous, + current, +}: { + type: IsmsDocumentType; + previous: IsmsPlatformData | null; + current: IsmsPlatformData; +}): { isStale: boolean; changedSources: string[] } { + if (!previous) { + return { isStale: true, changedSources: ['no-baseline'] }; + } + + const changes = computeChanges({ previous, current }); + const relevant = TYPE_DRIFT_SOURCES[type]; + const changedSources = relevant.filter((source) => changes[source]); + + return { isStale: changedSources.length > 0, changedSources }; +} + +/** Parse a stored JSON snapshot back into IsmsPlatformData. */ +export function parsePlatformSnapshot( + value: Prisma.JsonValue | null | undefined, +): IsmsPlatformData | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + // A platform snapshot always carries frameworkNames; older context-only + // snapshots are compatible because they share the same keys. + if (!('frameworkNames' in record)) return null; + + return { + organizationName: toStr(record.organizationName, 'The organization'), + frameworkNames: toStrArray(record.frameworkNames), + vendorCount: toNum(record.vendorCount), + subProcessorCount: toNum(record.subProcessorCount), + vendorsByCategory: toNumRecord(record.vendorsByCategory), + subProcessorNames: toStrArray(record.subProcessorNames), + infraVendorNames: toStrArray(record.infraVendorNames), + memberCount: toNum(record.memberCount), + membersByDepartment: toNumRecord(record.membersByDepartment), + deviceCount: toNum(record.deviceCount), + riskCount: toNum(record.riskCount), + highRiskCount: toNum(record.highRiskCount), + hasTrainingProgram: record.hasTrainingProgram === true, + wizardAnswers: parseStoredAnswers(record.wizardAnswers), + partiesFingerprint: toStr(record.partiesFingerprint, ''), + }; +} + +function toNum(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function toStr(value: unknown, fallback: string): string { + return typeof value === 'string' ? value : fallback; +} + +function toStrArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function toNumRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'number') result[key] = item; + } + return result; +} diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts new file mode 100644 index 0000000000..a902387a16 --- /dev/null +++ b/apps/api/src/isms/documents/types.ts @@ -0,0 +1,123 @@ +import type { IsmsContextSource } from '@db'; +import type { PartialWizardAnswers } from '../wizard/wizard-schema'; +import type { IsmsKeyValue } from '../utils/export-shared'; + +/** + * Platform data shared by every ISMS document derivation. Collected once per + * generate/drift/export call (always org-scoped) and passed to the per-document + * handler. Captured verbatim as the version sourceSnapshot for drift detection. + */ +export interface IsmsPlatformData { + organizationName: string; + /** Names of frameworks the organization is actively pursuing. */ + frameworkNames: string[]; + /** Total third-party vendors. */ + vendorCount: number; + /** Vendors flagged as sub-processors. */ + subProcessorCount: number; + /** Vendor counts keyed by category (cloud, software_as_a_service, ...). */ + vendorsByCategory: Record; + /** Names of vendors flagged as sub-processors. */ + subProcessorNames: string[]; + /** Names of cloud/infrastructure vendors (key dependencies). */ + infraVendorNames: string[]; + /** Total active (non-deactivated) workforce members. */ + memberCount: number; + /** Member counts keyed by department (it, hr, gov, ...). */ + membersByDepartment: Record; + /** Total managed endpoints/devices. */ + deviceCount: number; + /** Total risks in the register. */ + riskCount: number; + /** Risks at high/critical residual severity. */ + highRiskCount: number; + /** Whether the org has any training/awareness content configured. */ + hasTrainingProgram: boolean; + /** + * The org's saved ISMS wizard answers (CS-438) for this framework. Threaded + * into derivation so generated documents reflect the un-derivable inputs the + * customer supplied. Empty object when the wizard has not been filled in. + */ + wizardAnswers: PartialWizardAnswers; + /** + * Stable, order-insensitive fingerprint of the Interested Parties Register + * rows (id + name + category). The Requirements document (4.2c) derives one + * row per party, so a manual edit to a party — invisible to the rest of this + * snapshot — must still flag requirements drift. Empty string when the + * register has no rows yet. + */ + partiesFingerprint: string; +} + +/** A row destined for one of the ISMS registers (interested parties, etc.). */ +export interface DerivedRegisterRow { + source: IsmsContextSource; + derivedFrom: string; + position: number; +} + +export interface DerivedInterestedParty extends DerivedRegisterRow { + name: string; + category: string; + needsExpectations: string; +} + +export interface DerivedRequirement extends DerivedRegisterRow { + interestedPartyId: string | null; + partyName: string; + requirement: string; + treatment: string; +} + +export interface DerivedObjective extends DerivedRegisterRow { + objective: string; + target: string | null; + cadence: string | null; + plan: string | null; + measurementMethod: string | null; +} + +/** + * The organization profile that fills the narrative parts of the Context of the + * Organization document (clause 4.1) — overview table, mission, intended + * outcomes. Assembled from onboarding Q&A + the ISMS wizard at export time. + */ +export interface IsmsOrgProfile { + /** Key/value rows for the "Organization overview" table. */ + overview: IsmsKeyValue[]; + /** Company mission / description narrative, if captured. */ + mission: string | null; + /** Intended outcomes of the ISMS (customer-edited wizard answers or defaults). */ + intendedOutcomes: string[]; +} + +/** Everything a section builder needs to render a document's export sections. */ +export interface DocumentExportInput { + contextIssues: Array<{ + kind: string; + category: string | null; + description: string; + effect: string; + }>; + interestedParties: Array<{ + name: string; + category: string; + needsExpectations: string; + }>; + requirements: Array<{ + partyName: string; + requirement: string; + treatment: string; + }>; + objectives: Array<{ + objective: string; + target: string | null; + cadence: string | null; + status: string; + plan: string | null; + measurementMethod: string | null; + }>; + narrative: unknown; + /** Org overview/mission/outcomes — only populated for the Context document. */ + orgProfile?: IsmsOrgProfile; +} diff --git a/apps/api/src/isms/documents/wizard-helpers.ts b/apps/api/src/isms/documents/wizard-helpers.ts new file mode 100644 index 0000000000..facffa904e --- /dev/null +++ b/apps/api/src/isms/documents/wizard-helpers.ts @@ -0,0 +1,9 @@ +/** + * Helpers shared across the per-document derivations for consuming ISMS wizard + * answers (CS-438). + */ + +/** Strip the `custom:` prefix the wizard uses for free-text regulator entries. */ +export function formatRegulatorLabel(value: string): string { + return value.startsWith('custom:') ? value.slice('custom:'.length) : value; +} diff --git a/apps/api/src/isms/dto/ensure-isms-setup.dto.ts b/apps/api/src/isms/dto/ensure-isms-setup.dto.ts new file mode 100644 index 0000000000..e48c4e9943 --- /dev/null +++ b/apps/api/src/isms/dto/ensure-isms-setup.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class EnsureIsmsSetupDto { + @ApiProperty({ + description: 'ID of the framework to scope the ISMS setup to', + example: 'frk_abc123def456', + }) + @IsString() + frameworkId!: string; +} diff --git a/apps/api/src/isms/dto/export-isms-document.dto.ts b/apps/api/src/isms/dto/export-isms-document.dto.ts new file mode 100644 index 0000000000..2891e57eff --- /dev/null +++ b/apps/api/src/isms/dto/export-isms-document.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class ExportIsmsDocumentDto { + @ApiProperty({ + description: 'File format to export the ISMS document as', + enum: ['pdf', 'docx'], + example: 'pdf', + }) + @IsIn(['pdf', 'docx']) + format!: 'pdf' | 'docx'; +} diff --git a/apps/api/src/isms/dto/link-isms-controls.dto.ts b/apps/api/src/isms/dto/link-isms-controls.dto.ts new file mode 100644 index 0000000000..a9dfd0ff74 --- /dev/null +++ b/apps/api/src/isms/dto/link-isms-controls.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsString } from 'class-validator'; + +export class LinkIsmsControlsDto { + @ApiProperty({ + description: 'IDs of the controls to link to the ISMS document', + type: [String], + example: ['ctl_abc123def456', 'ctl_ghi789jkl012'], + }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + controlIds!: string[]; +} diff --git a/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts b/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts new file mode 100644 index 0000000000..0be57e7f79 --- /dev/null +++ b/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class SubmitIsmsForApprovalDto { + @ApiProperty({ + description: 'Member ID of the approver to submit the ISMS to', + example: 'mem_abc123def456', + }) + @IsString() + approverId!: string; +} diff --git a/apps/api/src/isms/isms-context-issue.service.spec.ts b/apps/api/src/isms/isms-context-issue.service.spec.ts new file mode 100644 index 0000000000..568a199762 --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.spec.ts @@ -0,0 +1,158 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextIssueService } from './isms-context-issue.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsContextIssue: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsContextIssueService', () => { + let service: IsmsContextIssueService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsContextIssueService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { kind: 'internal' as const, description: 'd', effect: 'e' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual issue with the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + position: 2, + }); + (mockDb.ismsContextIssue.create as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + + await service.create(args); + + expect(mockDb.ismsContextIssue.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + documentId: 'doc_1', + source: 'manual', + position: 3, + }), + }); + }); + }); + + describe('update', () => { + const args = { + issueId: 'ci_1', + organizationId: 'org_1', + dto: { description: 'updated' }, + }; + + it('throws NotFoundException when issue not in org', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit (override)', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + source: 'derived', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + + await service.update(args); + + expect(mockDb.ismsContextIssue.update).toHaveBeenCalledWith({ + where: { id: 'ci_1' }, + data: expect.objectContaining({ + description: 'updated', + source: 'manual', + }), + }); + }); + + it('scopes the lookup by organization', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + source: 'manual', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsContextIssue.findFirst).toHaveBeenCalledWith({ + where: { id: 'ci_1', document: { organizationId: 'org_1' } }, + }); + }); + }); + + describe('remove', () => { + const args = { issueId: 'ci_1', organizationId: 'org_1' }; + + it('throws NotFoundException when issue not in org', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the issue', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + (mockDb.ismsContextIssue.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove(args); + + expect(mockDb.ismsContextIssue.delete).toHaveBeenCalledWith({ + where: { id: 'ci_1' }, + }); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + issueId: 'ci_1', + organizationId: 'org_1', + dto: { description: 'updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-context-issue.service.ts b/apps/api/src/isms/isms-context-issue.service.ts new file mode 100644 index 0000000000..7d2fa066be --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.ts @@ -0,0 +1,143 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateContextIssueInput, + UpdateContextIssueInput, +} from './registers/register-registry'; + +/** + * CRUD for the Context-of-the-Organization (clause 4.1) issue register. Derived + * rows are written by IsmsService.generate; this service handles manual edits and + * overrides. Editing a derived row flips its source to 'manual' so the override is + * preserved across regeneration. + */ +@Injectable() +export class IsmsContextIssueService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateContextIssueInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsContextIssue.create({ + data: { + documentId, + kind: dto.kind, + category: dto.category ?? null, + description: dto.description, + effect: dto.effect, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + issueId, + organizationId, + dto, + }: { + issueId: string; + organizationId: string; + dto: UpdateContextIssueInput; + }) { + const issue = await this.requireIssue({ issueId, organizationId }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: issue.documentId }); + return tx.ismsContextIssue.update({ + where: { id: issueId }, + data: { + kind: dto.kind ?? undefined, + category: dto.category ?? undefined, + description: dto.description ?? undefined, + effect: dto.effect ?? undefined, + position: dto.position ?? undefined, + // Editing a derived row records the override by flipping it to manual. + source: 'manual', + }, + }); + }); + } + + async remove({ + issueId, + organizationId, + }: { + issueId: string; + organizationId: string; + }) { + const issue = await this.requireIssue({ issueId, organizationId }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: issue.documentId }); + await tx.ismsContextIssue.delete({ where: { id: issueId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsContextIssue.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireIssue({ + issueId, + organizationId, + }: { + issueId: string; + organizationId: string; + }) { + const issue = await db.ismsContextIssue.findFirst({ + where: { id: issueId, document: { organizationId } }, + }); + if (!issue) { + throw new NotFoundException('Context issue not found'); + } + return issue; + } +} diff --git a/apps/api/src/isms/isms-context.service.spec.ts b/apps/api/src/isms/isms-context.service.spec.ts new file mode 100644 index 0000000000..a91d741002 --- /dev/null +++ b/apps/api/src/isms/isms-context.service.spec.ts @@ -0,0 +1,308 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextService } from './isms-context.service'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { + diffPlatformSnapshots, + parsePlatformSnapshot, +} from './documents/snapshot'; +import { buildExportSections } from './documents/registry'; +import { generateIsmsExportFile } from './utils/export-generator'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { findFirst: jest.fn() }, + organization: { findUnique: jest.fn() }, + context: { findMany: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, + $transaction: jest.fn(), + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./documents/snapshot', () => ({ + diffPlatformSnapshots: jest.fn(), + parsePlatformSnapshot: jest.fn(), +})); +jest.mock('./documents/registry', () => ({ + buildExportSections: jest.fn(), +})); +jest.mock('./utils/export-generator', () => ({ + generateIsmsExportFile: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); +const mockRun = jest.mocked(runDerivation); +const mockDiff = jest.mocked(diffPlatformSnapshots); +const mockParse = jest.mocked(parsePlatformSnapshot); +const mockBuild = jest.mocked(buildExportSections); +const mockExport = jest.mocked(generateIsmsExportFile); + +const snapshot = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub Co'], + infraVendorNames: ['Cloud Co'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('IsmsContextService', () => { + let service: IsmsContextService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsContextService(); + }); + + describe('generate', () => { + const args = { documentId: 'doc_1', organizationId: 'org_1' }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.generate(args)).rejects.toThrow(NotFoundException); + }); + + it.each([ + 'context_of_organization', + 'interested_parties_register', + 'interested_parties_requirements', + 'objectives_plan', + 'isms_scope', + 'leadership_commitment', + ])('dispatches derivation + snapshot for type %s', async (type) => { + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ id: 'doc_1', type, frameworkId: 'fw_1' }) + .mockResolvedValueOnce({ id: 'doc_1' }); + mockCollect.mockResolvedValue(snapshot); + const tx = {}; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + + await service.generate(args); + + expect(mockRun).toHaveBeenCalledWith({ + tx, + type, + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: snapshot, + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalledWith({ + tx, + documentId: 'doc_1', + snapshot, + }); + }); + + it('reuses pre-collected data and skips collectPlatformData', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'doc_1', + type: 'objectives_plan', + frameworkId: 'fw_1', + }) + .mockResolvedValueOnce({ id: 'doc_1' }); + const tx = {}; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + const precollected = { ...snapshot, riskCount: 42 }; + + await service.generate({ ...args, data: precollected }); + + expect(mockCollect).not.toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalledWith({ + tx, + type: 'objectives_plan', + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: precollected, + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalledWith({ + tx, + documentId: 'doc_1', + snapshot: precollected, + }); + }); + }); + + describe('drift', () => { + const args = { documentId: 'doc_1', organizationId: 'org_1' }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.drift(args)).rejects.toThrow(NotFoundException); + }); + + it('compares current data against the parsed snapshot by type', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'objectives_plan', + frameworkId: 'fw_1', + versions: [{ sourceSnapshot: snapshot }], + }); + mockCollect.mockResolvedValue({ ...snapshot, riskCount: 9 }); + mockParse.mockReturnValue(snapshot); + mockDiff.mockReturnValue({ isStale: true, changedSources: ['risks'] }); + + const result = await service.drift(args); + + expect(mockParse).toHaveBeenCalledWith(snapshot); + expect(mockDiff).toHaveBeenCalledWith({ + type: 'objectives_plan', + previous: snapshot, + current: { ...snapshot, riskCount: 9 }, + }); + expect(result).toEqual({ isStale: true, changedSources: ['risks'] }); + }); + + it('treats a missing snapshot as no baseline', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'context_of_organization', + frameworkId: 'fw_1', + versions: [], + }); + mockCollect.mockResolvedValue(snapshot); + mockParse.mockReturnValue(null); + mockDiff.mockReturnValue({ + isStale: true, + changedSources: ['no-baseline'], + }); + + await service.drift(args); + + expect(mockDiff).toHaveBeenCalledWith({ + type: 'context_of_organization', + previous: null, + current: snapshot, + }); + }); + }); + + describe('exportDocument', () => { + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format: 'pdf' }, + }), + ).rejects.toThrow(NotFoundException); + }); + + const buildDocument = (type: string) => ({ + id: 'doc_1', + type, + title: 'Doc', + status: 'approved', + preparedBy: 'Comp AI', + approvedAt: null, + declinedAt: null, + framework: { name: 'ISO 27001' }, + organization: { name: 'Acme', primaryColor: '#004D3D' }, + approver: { user: { name: 'Jane', email: 'jane@acme.io' } }, + contextIssues: [ + { + kind: 'external', + category: 'Regulatory & Legal', + description: 'd', + effect: 'e', + }, + ], + interestedParties: [ + { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + ], + interestedPartyRequirements: [ + { partyName: 'Customers', requirement: 'r', treatment: 't' }, + ], + objectives: [ + { + objective: 'o', + target: 't', + cadence: 'Annual', + status: 'on_track', + plan: 'p', + measurementMethod: 'm', + }, + ], + versions: [ + { version: 2, narrative: { statement: 's', commitments: [] } }, + ], + }); + + it.each([ + ['context_of_organization', 'pdf'], + ['interested_parties_register', 'pdf'], + ['interested_parties_requirements', 'pdf'], + ['objectives_plan', 'pdf'], + ['isms_scope', 'docx'], + ['leadership_commitment', 'docx'], + ] as const)( + 'dispatches export sections for %s (%s)', + async (type, format) => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue( + buildDocument(type), + ); + // The Context document loads the org profile (org + Context Q&A + + // ISMS wizard answers); other types skip it. Stub all three reads. + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Acme', + website: 'https://acme.io', + }); + (mockDb.context.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsProfile.findUnique as jest.Mock).mockResolvedValue({ + answers: {}, + }); + mockBuild.mockReturnValue([{ heading: 'H' }]); + mockExport.mockResolvedValue({ + fileBuffer: Buffer.from('bytes'), + mimeType: format === 'pdf' ? 'application/pdf' : 'docx-mime', + filename: `doc.${format}`, + }); + + const result = await service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format }, + }); + + expect(mockBuild).toHaveBeenCalledWith( + expect.objectContaining({ type }), + ); + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ + format, + sections: [{ heading: 'H' }], + metadata: expect.objectContaining({ + title: 'Doc', + organizationName: 'Acme', + primaryColor: '#004D3D', + }), + }), + ); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + }, + ); + }); +}); diff --git a/apps/api/src/isms/isms-context.service.ts b/apps/api/src/isms/isms-context.service.ts new file mode 100644 index 0000000000..ec3e995ab0 --- /dev/null +++ b/apps/api/src/isms/isms-context.service.ts @@ -0,0 +1,205 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.dto'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { loadOrgProfile } from './documents/org-profile'; +import { buildExportSections } from './documents/registry'; +import { + diffPlatformSnapshots, + parsePlatformSnapshot, +} from './documents/snapshot'; +import type { DocumentExportInput, IsmsPlatformData } from './documents/types'; +import { + generateIsmsExportFile, + type IsmsExportResult, +} from './utils/export-generator'; +import { buildExportMetadata } from './utils/export-metadata'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +const DOCUMENT_INCLUDE = { + versions: { where: { isLatest: true }, take: 1 }, + contextIssues: { orderBy: { position: 'asc' } }, + interestedParties: { orderBy: { position: 'asc' } }, + interestedPartyRequirements: { orderBy: { position: 'asc' } }, + objectives: { orderBy: { position: 'asc' } }, +} as const; + +/** + * ISMS document derivation, drift detection and export. Dispatches by document + * type to the per-document handlers under ./documents. Document lifecycle + * (approve/decline/submit) lives in IsmsService; register CRUD in the register + * services. + */ +@Injectable() +export class IsmsContextService { + async generate({ + documentId, + organizationId, + data: precollected, + }: { + documentId: string; + organizationId: string; + /** + * Pre-collected platform data, reused instead of re-querying. Passed by + * generateAll so the expensive multi-query collect runs once for the whole + * batch instead of once per document. Omit for a standalone regenerate. + */ + data?: IsmsPlatformData; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const data = + precollected ?? + (await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + })); + + await db.$transaction(async (tx) => { + await runDerivation({ + tx, + type: document.type, + documentId, + organizationId, + frameworkId: document.frameworkId, + data, + }); + await upsertLatestSnapshotVersion({ tx, documentId, snapshot: data }); + }); + + return this.loadDocument({ documentId, organizationId }); + } + + async drift({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }): Promise<{ isStale: boolean; changedSources: string[] }> { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { versions: { where: { isLatest: true }, take: 1 } }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const current = await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + }); + const previous = parsePlatformSnapshot( + document.versions[0]?.sourceSnapshot, + ); + + return diffPlatformSnapshots({ type: document.type, previous, current }); + } + + async exportDocument({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: ExportIsmsDocumentDto; + }): Promise { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { + framework: { select: { name: true } }, + organization: { + select: { name: true, website: true, primaryColor: true }, + }, + approver: { select: { user: { select: { name: true, email: true } } } }, + ...DOCUMENT_INCLUDE, + }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + // The Context document (clause 4.1) renders an org overview, mission and + // intended outcomes; other document types don't need the profile. + const orgProfile = + document.type === 'context_of_organization' + ? await loadOrgProfile({ + organizationId, + frameworkId: document.frameworkId, + }) + : undefined; + + const input: DocumentExportInput = { + contextIssues: document.contextIssues.map((issue) => ({ + kind: issue.kind, + category: issue.category, + description: issue.description, + effect: issue.effect, + })), + interestedParties: document.interestedParties.map((party) => ({ + name: party.name, + category: party.category, + needsExpectations: party.needsExpectations, + })), + requirements: document.interestedPartyRequirements.map((row) => ({ + partyName: row.partyName, + requirement: row.requirement, + treatment: row.treatment, + })), + objectives: document.objectives.map((objective) => ({ + objective: objective.objective, + target: objective.target, + cadence: objective.cadence, + status: objective.status, + plan: objective.plan, + measurementMethod: objective.measurementMethod, + })), + narrative: document.versions[0]?.narrative ?? null, + orgProfile, + }; + + const sections = buildExportSections({ type: document.type, input }); + + return generateIsmsExportFile({ + sections, + format: dto.format, + metadata: buildExportMetadata({ + type: document.type, + title: document.title, + frameworkName: document.framework.name || 'ISO 27001', + version: document.versions[0]?.version ?? 1, + status: document.status, + preparedBy: document.preparedBy, + owner: null, + approverName: + document.approver?.user?.name || + document.approver?.user?.email || + null, + approvedAt: document.approvedAt, + declinedAt: document.declinedAt, + organizationName: document.organization.name, + primaryColor: document.organization.primaryColor, + }), + }); + } + + private async loadDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + return db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: DOCUMENT_INCLUDE, + }); + } +} diff --git a/apps/api/src/isms/isms-document-control.service.spec.ts b/apps/api/src/isms/isms-document-control.service.spec.ts new file mode 100644 index 0000000000..c91cb7c11d --- /dev/null +++ b/apps/api/src/isms/isms-document-control.service.spec.ts @@ -0,0 +1,199 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsDocumentControlService } from './isms-document-control.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsDocumentControlService', () => { + let service: IsmsDocumentControlService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsDocumentControlService(); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + { id: 'ctl_2' }, + ]); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 2, + }); + (mockDb.ismsDocumentControlLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + }); + + describe('addControls', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }; + + it('throws NotFoundException when the document is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.addControls(args)).rejects.toThrow(NotFoundException); + }); + + it('rejects controls that do not belong to the org', async () => { + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + ]); + await expect(service.addControls(args)).rejects.toThrow( + BadRequestException, + ); + expect(mockDb.ismsDocumentControlLink.createMany).not.toHaveBeenCalled(); + }); + + it('verifies controls are org-scoped', async () => { + await service.addControls(args); + expect(mockDb.control.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['ctl_1', 'ctl_2'] }, organizationId: 'org_1' }, + select: { id: true }, + }); + }); + + it('creates links idempotently and de-duplicates input', async () => { + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocumentControlLink.createMany).toHaveBeenCalledWith({ + data: [ + { ismsDocumentId: 'doc_1', controlId: 'ctl_1' }, + { ismsDocumentId: 'doc_1', controlId: 'ctl_2' }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('removeControl', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }; + + it('throws NotFoundException when the document is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.removeControl(args)).rejects.toThrow( + NotFoundException, + ); + }); + + it('deletes the link scoped to the document', async () => { + await service.removeControl(args); + expect(mockDb.ismsDocumentControlLink.deleteMany).toHaveBeenCalledWith({ + where: { ismsDocumentId: 'doc_1', controlId: 'ctl_1' }, + }); + }); + }); + + describe('approval invalidation', () => { + it('reverts an approved document to draft on control add', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); + + it('reverts an approved document to draft on control remove', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + + await service.removeControl({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); + + it('leaves a draft document untouched on control add', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('does not invalidate an approved document on an idempotent add (no rows inserted)', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + // createMany inserts nothing because the links already exist. + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('does not invalidate an approved document on a no-op remove (no rows deleted)', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsDocumentControlLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + + await service.removeControl({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/isms/isms-document-control.service.ts b/apps/api/src/isms/isms-document-control.service.ts new file mode 100644 index 0000000000..c3454f5c9e --- /dev/null +++ b/apps/api/src/isms/isms-document-control.service.ts @@ -0,0 +1,97 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; + +/** + * Org-level mapping between an ISMS document and the organization's Controls + * (CS-437). Mirrors the Policy<->Control mapping but over the explicit + * IsmsDocumentControlLink junction. Everything is org-scoped: the document and + * every control must belong to the caller's organization. + */ +@Injectable() +export class IsmsDocumentControlService { + async addControls({ + documentId, + organizationId, + controlIds, + }: { + documentId: string; + organizationId: string; + controlIds: string[]; + }) { + await this.requireDocument({ documentId, organizationId }); + + const uniqueControlIds = Array.from(new Set(controlIds)); + const controls = await db.control.findMany({ + where: { id: { in: uniqueControlIds }, organizationId }, + select: { id: true }, + }); + if (controls.length !== uniqueControlIds.length) { + throw new BadRequestException( + 'One or more controls do not belong to the organization', + ); + } + + // Mutating an approved document's control mappings invalidates its sign-off, + // so revert it to draft in the same transaction as the write (mirrors the + // register/narrative edits). Only a REAL change invalidates — an idempotent + // re-link that inserts nothing must not downgrade an approved document. + await db.$transaction(async (tx) => { + const { count } = await tx.ismsDocumentControlLink.createMany({ + data: uniqueControlIds.map((controlId) => ({ + ismsDocumentId: documentId, + controlId, + })), + skipDuplicates: true, + }); + if (count > 0) { + await invalidateApprovalIfNeeded({ tx, documentId }); + } + }); + return { message: 'Controls linked' }; + } + + async removeControl({ + documentId, + organizationId, + controlId, + }: { + documentId: string; + organizationId: string; + controlId: string; + }) { + await this.requireDocument({ documentId, organizationId }); + // Only a real unlink (a row actually deleted) invalidates sign-off; removing + // a control that wasn't linked must not downgrade an approved document. + await db.$transaction(async (tx) => { + const { count } = await tx.ismsDocumentControlLink.deleteMany({ + where: { ismsDocumentId: documentId, controlId }, + }); + if (count > 0) { + await invalidateApprovalIfNeeded({ tx, documentId }); + } + }); + return { message: 'Control unlinked' }; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + select: { id: true }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } +} diff --git a/apps/api/src/isms/isms-interested-party.service.spec.ts b/apps/api/src/isms/isms-interested-party.service.spec.ts new file mode 100644 index 0000000000..31a1adc2c1 --- /dev/null +++ b/apps/api/src/isms/isms-interested-party.service.spec.ts @@ -0,0 +1,148 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsInterestedParty: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsInterestedPartyService', () => { + let service: IsmsInterestedPartyService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsInterestedPartyService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual party at the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + position: 3, + }); + (mockDb.ismsInterestedParty.create as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + + await service.create(args); + + expect(mockDb.ismsInterestedParty.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ source: 'manual', position: 4 }), + }); + }); + }); + + describe('update', () => { + const args = { + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'Updated' }, + }; + + it('throws NotFoundException when party not in org', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue( + null, + ); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + source: 'derived', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsInterestedParty.update).toHaveBeenCalledWith({ + where: { id: 'ip_1' }, + data: expect.objectContaining({ name: 'Updated', source: 'manual' }), + }); + }); + + it('scopes the lookup by organization', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + await service.update(args); + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_1', document: { organizationId: 'org_1' } }, + }); + }); + }); + + describe('remove', () => { + const args = { partyId: 'ip_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue( + null, + ); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the party', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + (mockDb.ismsInterestedParty.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove(args); + expect(mockDb.ismsInterestedParty.delete).toHaveBeenCalledWith({ + where: { id: 'ip_1' }, + }); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'Updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-interested-party.service.ts b/apps/api/src/isms/isms-interested-party.service.ts new file mode 100644 index 0000000000..58b3dcbf8e --- /dev/null +++ b/apps/api/src/isms/isms-interested-party.service.ts @@ -0,0 +1,140 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateInterestedPartyInput, + UpdateInterestedPartyInput, +} from './registers/register-registry'; + +/** + * CRUD for the Interested Parties register (clause 4.2a). Derived rows are written + * by IsmsContextService.generate; this service handles manual edits and overrides. + * Editing a derived row flips its source to 'manual' so the override survives + * regeneration. + */ +@Injectable() +export class IsmsInterestedPartyService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateInterestedPartyInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsInterestedParty.create({ + data: { + documentId, + name: dto.name, + category: dto.category, + needsExpectations: dto.needsExpectations, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + partyId, + organizationId, + dto, + }: { + partyId: string; + organizationId: string; + dto: UpdateInterestedPartyInput; + }) { + const party = await this.requireParty({ partyId, organizationId }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: party.documentId }); + return tx.ismsInterestedParty.update({ + where: { id: partyId }, + data: { + name: dto.name ?? undefined, + category: dto.category ?? undefined, + needsExpectations: dto.needsExpectations ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + partyId, + organizationId, + }: { + partyId: string; + organizationId: string; + }) { + const party = await this.requireParty({ partyId, organizationId }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: party.documentId }); + await tx.ismsInterestedParty.delete({ where: { id: partyId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsInterestedParty.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireParty({ + partyId, + organizationId, + }: { + partyId: string; + organizationId: string; + }) { + const party = await db.ismsInterestedParty.findFirst({ + where: { id: partyId, document: { organizationId } }, + }); + if (!party) { + throw new NotFoundException('Interested party not found'); + } + return party; + } +} diff --git a/apps/api/src/isms/isms-narrative.service.spec.ts b/apps/api/src/isms/isms-narrative.service.spec.ts new file mode 100644 index 0000000000..a1e6db166e --- /dev/null +++ b/apps/api/src/isms/isms-narrative.service.spec.ts @@ -0,0 +1,153 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsNarrativeService } from './isms-narrative.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + ismsDocumentVersion: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +const validScope = { + certificateScopeSentence: 'The ISMS covers Acme.', + inScope: 'Everything.', + interfaces: ['Suppliers'], + dependencies: ['Cloud'], + exclusions: [], +}; + +describe('IsmsNarrativeService', () => { + let service: IsmsNarrativeService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsNarrativeService(); + }); + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects a register-type document that has no narrative schema', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'interested_parties_register', + }); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects a narrative that fails zod validation', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: { certificateScopeSentence: 123 }, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('creates a version when none exists', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.ismsDocumentVersion.create as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocumentVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ documentId: 'doc_1', version: 1 }), + }), + ); + }); + + it('updates the latest version when present', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + (mockDb.ismsDocumentVersion.update as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocumentVersion.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'ver_1' } }), + ); + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + (mockDb.ismsDocumentVersion.update as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-narrative.service.ts b/apps/api/src/isms/isms-narrative.service.ts new file mode 100644 index 0000000000..fd06a3ceea --- /dev/null +++ b/apps/api/src/isms/isms-narrative.service.ts @@ -0,0 +1,78 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { narrativeSchemaForType } from './documents/registry'; +import { invalidateApprovalIfNeeded } from './utils/approval'; + +/** + * Saves the singleton-document narrative (clauses 4.3 ISMS Scope and 5.1 + * Leadership Commitment) into the document's latest version. The payload is + * validated against the per-type Zod schema before persisting. + */ +@Injectable() +export class IsmsNarrativeService { + async save({ + documentId, + organizationId, + narrative, + }: { + documentId: string; + organizationId: string; + narrative: unknown; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const schema = narrativeSchemaForType(document.type); + if (!schema) { + throw new BadRequestException( + `Document type ${document.type} does not store a narrative`, + ); + } + + const parsed = schema.safeParse(narrative); + if (!parsed.success) { + throw new BadRequestException( + `Invalid narrative: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')} ${issue.message}`) + .join('; ')}`, + ); + } + + const value: Prisma.InputJsonValue = JSON.parse( + JSON.stringify(parsed.data), + ); + + // The latest-version read, approval invalidation, and the narrative write + // must all be atomic: reading the latest version outside the transaction + // lets a concurrent save update a stale version or hit the unique + // constraint, and a failed write must not leave the document reverted to + // draft without the new content. + return db.$transaction(async (tx) => { + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + + await invalidateApprovalIfNeeded({ tx, documentId }); + + if (latest) { + return tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { narrative: value }, + }); + } + + return tx.ismsDocumentVersion.create({ + data: { documentId, version: 1, isLatest: true, narrative: value }, + }); + }); + } +} diff --git a/apps/api/src/isms/isms-objective.service.spec.ts b/apps/api/src/isms/isms-objective.service.spec.ts new file mode 100644 index 0000000000..03e24c59cf --- /dev/null +++ b/apps/api/src/isms/isms-objective.service.spec.ts @@ -0,0 +1,211 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsObjectiveService } from './isms-objective.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + member: { findFirst: jest.fn() }, + ismsObjective: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsObjectiveService', () => { + let service: IsmsObjectiveService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsObjectiveService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'Maintain ISO 27001', status: 'on_track' as const }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual objective with status + next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + position: 1, + }); + (mockDb.ismsObjective.create as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + + await service.create(args); + + expect(mockDb.ismsObjective.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + source: 'manual', + position: 2, + status: 'on_track', + }), + }); + }); + + it('defaults status to not_started', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsObjective.count as jest.Mock).mockResolvedValue(0); + (mockDb.ismsObjective.create as jest.Mock).mockResolvedValue({}); + await service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'X' }, + }); + const call = (mockDb.ismsObjective.create as jest.Mock).mock.calls[0][0]; + expect(call.data.status).toBe('not_started'); + }); + + it('throws NotFoundException when the owner is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'X', ownerMemberId: 'mem_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_other', organizationId: 'org_1' }, + }); + expect(mockDb.ismsObjective.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + const args = { + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' as const, ownerMemberId: 'mem_1' }, + }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + source: 'derived', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsObjective.update).toHaveBeenCalledWith({ + where: { id: 'obj_1' }, + data: expect.objectContaining({ + status: 'met', + ownerMemberId: 'mem_1', + source: 'manual', + }), + }); + }); + + it('throws NotFoundException when the owner is not in the org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { ownerMemberId: 'mem_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_other', organizationId: 'org_1' }, + }); + }); + + it('clears the owner when given an empty string', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { ownerMemberId: ' ' }, + }); + + expect(mockDb.member.findFirst).not.toHaveBeenCalled(); + expect(mockDb.ismsObjective.update).toHaveBeenCalledWith({ + where: { id: 'obj_1' }, + data: expect.objectContaining({ ownerMemberId: null }), + }); + }); + }); + + describe('remove', () => { + const args = { objectiveId: 'obj_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the objective', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.ismsObjective.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove(args); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-objective.service.ts b/apps/api/src/isms/isms-objective.service.ts new file mode 100644 index 0000000000..98a5986930 --- /dev/null +++ b/apps/api/src/isms/isms-objective.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateObjectiveInput, + UpdateObjectiveInput, +} from './registers/register-registry'; + +/** + * CRUD for the Information Security Objectives register (clause 6.2). Derived rows + * are written by IsmsContextService.generate; this service handles manual edits and + * status/owner updates. Editing a derived row flips its source to 'manual'. + */ +@Injectable() +export class IsmsObjectiveService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateObjectiveInput; + }) { + await this.requireDocument({ documentId, organizationId }); + const ownerMemberId = await this.resolveOwner({ + ownerMemberId: dto.ownerMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsObjective.create({ + data: { + documentId, + objective: dto.objective, + target: dto.target ?? null, + ownerMemberId: ownerMemberId ?? null, + cadence: dto.cadence ?? null, + plan: dto.plan ?? null, + measurementMethod: dto.measurementMethod ?? null, + status: dto.status ?? 'not_started', + source: 'manual', + position, + }, + }); + }); + } + + async update({ + objectiveId, + organizationId, + dto, + }: { + objectiveId: string; + organizationId: string; + dto: UpdateObjectiveInput; + }) { + const objective = await this.requireObjective({ + objectiveId, + organizationId, + }); + // undefined = field omitted (leave as-is); empty string = clear the owner. + const ownerFieldProvided = dto.ownerMemberId !== undefined; + const ownerMemberId = await this.resolveOwner({ + ownerMemberId: dto.ownerMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: objective.documentId, + }); + return tx.ismsObjective.update({ + where: { id: objectiveId }, + data: { + objective: dto.objective ?? undefined, + target: dto.target ?? undefined, + ownerMemberId: ownerFieldProvided ? ownerMemberId : undefined, + cadence: dto.cadence ?? undefined, + plan: dto.plan ?? undefined, + measurementMethod: dto.measurementMethod ?? undefined, + status: dto.status ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + objectiveId, + organizationId, + }: { + objectiveId: string; + organizationId: string; + }) { + const objective = await this.requireObjective({ + objectiveId, + organizationId, + }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: objective.documentId, + }); + await tx.ismsObjective.delete({ where: { id: objectiveId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsObjective.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + /** + * Resolve an objective owner. `undefined` (field omitted) is passed through so + * the caller can leave it untouched; an empty/whitespace value clears it; a + * non-empty id must resolve to a member of the document's organization (mirrors + * submitForApproval's approver check). + */ + private async resolveOwner({ + ownerMemberId, + organizationId, + }: { + ownerMemberId: string | undefined; + organizationId: string; + }): Promise { + if (ownerMemberId === undefined) { + return undefined; + } + const trimmed = ownerMemberId.trim(); + if (!trimmed) { + return null; + } + const member = await db.member.findFirst({ + where: { id: trimmed, organizationId }, + }); + if (!member) { + throw new NotFoundException('Owner not found in organization'); + } + return trimmed; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireObjective({ + objectiveId, + organizationId, + }: { + objectiveId: string; + organizationId: string; + }) { + const objective = await db.ismsObjective.findFirst({ + where: { id: objectiveId, document: { organizationId } }, + }); + if (!objective) { + throw new NotFoundException('Objective not found'); + } + return objective; + } +} diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts new file mode 100644 index 0000000000..fe99b08ae0 --- /dev/null +++ b/apps/api/src/isms/isms-registers.controller.spec.ts @@ -0,0 +1,264 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import { IsmsRegistersController } from './isms-registers.controller'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('./isms-context-issue.service', () => ({ + IsmsContextIssueService: class {}, +})); +jest.mock('./isms-interested-party.service', () => ({ + IsmsInterestedPartyService: class {}, +})); +jest.mock('./isms-requirement.service', () => ({ + IsmsRequirementService: class {}, +})); +jest.mock('./isms-objective.service', () => ({ + IsmsObjectiveService: class {}, +})); +jest.mock('./isms-narrative.service', () => ({ + IsmsNarrativeService: class {}, +})); + +const reqWith = (body: Record) => + ({ body }) as unknown as Request; + +describe('IsmsRegistersController', () => { + let controller: IsmsRegistersController; + + const contextIssueService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const interestedPartyService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const requirementService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const objectiveService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const narrativeService = { save: jest.fn() }; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsRegistersController], + providers: [ + { provide: IsmsContextIssueService, useValue: contextIssueService }, + { + provide: IsmsInterestedPartyService, + useValue: interestedPartyService, + }, + { provide: IsmsRequirementService, useValue: requirementService }, + { provide: IsmsObjectiveService, useValue: objectiveService }, + { provide: IsmsNarrativeService, useValue: narrativeService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsRegistersController); + jest.clearAllMocks(); + }); + + describe('createRow', () => { + it('dispatches interested-parties create with documentId, parsed dto, org', async () => { + const dto = { + name: 'Customers', + category: 'Customer', + needsExpectations: 'n', + }; + await controller.createRow( + 'doc_1', + 'interested-parties', + reqWith(dto), + 'org_1', + ); + expect(interestedPartyService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + }); + + it('dispatches context-issues create and passes category through', async () => { + const body = { + kind: 'internal', + category: 'Strategic', + description: 'd', + effect: 'e', + }; + await controller.createRow( + 'doc_1', + 'context-issues', + reqWith(body), + 'org_1', + ); + expect(contextIssueService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches requirements create with parsed dto', async () => { + const body = { partyName: 'C', requirement: 'r', treatment: 't' }; + await controller.createRow( + 'doc_1', + 'requirements', + reqWith(body), + 'org_1', + ); + expect(requirementService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches objectives create with parsed dto', async () => { + const body = { objective: 'o' }; + await controller.createRow('doc_1', 'objectives', reqWith(body), 'org_1'); + expect(objectiveService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.createRow('doc_1', 'nope', reqWith({}), 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('updateRow', () => { + it('dispatches context-issues update with issueId, parsed dto, org', async () => { + const body = { description: 'updated' }; + await controller.updateRow( + 'context-issues', + 'row1', + reqWith(body), + 'org_1', + ); + expect(contextIssueService.update).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches interested-parties update with partyId', async () => { + const body = { name: 'X' }; + await controller.updateRow( + 'interested-parties', + 'ip_1', + reqWith(body), + 'org_1', + ); + expect(interestedPartyService.update).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.updateRow('nope', 'row1', reqWith({}), 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('deleteRow', () => { + it('dispatches objectives remove with objectiveId and org', async () => { + await controller.deleteRow('objectives', 'row1', 'org_1'); + expect(objectiveService.remove).toHaveBeenCalledWith({ + objectiveId: 'row1', + organizationId: 'org_1', + }); + }); + + it('dispatches requirements remove with requirementId and org', async () => { + await controller.deleteRow('requirements', 'req_1', 'org_1'); + expect(requirementService.remove).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.deleteRow('nope', 'row1', 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + it('saveNarrative reads req.body.narrative and passes it through', async () => { + await controller.saveNarrative( + 'doc_1', + reqWith({ narrative: { statement: 's' } }), + 'org_1', + ); + expect(narrativeService.save).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: { statement: 's' }, + }); + }); + + describe('permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsRegistersController) => + reflector.get(PERMISSIONS_KEY, IsmsRegistersController.prototype[method]); + + it('gates every mutation with evidence:update', () => { + for (const method of [ + 'createRow', + 'updateRow', + 'deleteRow', + 'saveNarrative', + ] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); + }); +}); diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts new file mode 100644 index 0000000000..6528372300 --- /dev/null +++ b/apps/api/src/isms/isms-registers.controller.ts @@ -0,0 +1,203 @@ +import { + BadRequestException, + Controller, + Delete, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '@/auth/auth-context.decorator'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; +import { + createRegisterRegistry, + type IsmsRegisterKey, + type RegisterHandler, +} from './registers/register-registry'; + +/** + * OpenAPI body contracts for the generic register endpoints. They read `req.body` + * directly (the global ValidationPipe mangles nested JSON), so the request shape + * is documented explicitly here. Each register accepts its own fields — the union + * below covers every register's row; per-register validation is enforced at + * runtime by the registry's zod schemas. Mirrors the inline-schema @ApiBody used + * by the policies controller for its @Req()-bodied endpoints. + */ +const REGISTER_ROW_BODY = { + description: 'Register row fields (per-register; validated at runtime by zod)', + schema: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['internal', 'external'] }, + category: { type: 'string' }, + description: { type: 'string' }, + effect: { type: 'string' }, + name: { type: 'string' }, + needsExpectations: { type: 'string' }, + interestedPartyId: { type: 'string' }, + partyName: { type: 'string' }, + requirement: { type: 'string' }, + treatment: { type: 'string' }, + objective: { type: 'string' }, + target: { type: 'string' }, + ownerMemberId: { type: 'string' }, + cadence: { type: 'string' }, + plan: { type: 'string' }, + measurementMethod: { type: 'string' }, + status: { + type: 'string', + enum: ['not_started', 'on_track', 'at_risk', 'met'], + }, + position: { type: 'integer', minimum: 0 }, + }, + }, +} as const; + +const NARRATIVE_BODY = { + description: 'Singleton document narrative payload', + schema: { + type: 'object', + properties: { + narrative: { + type: 'object', + description: + 'Per-type narrative object (e.g. ISMS scope or leadership commitment), validated at runtime by zod', + additionalProperties: true, + }, + }, + required: ['narrative'], + }, +} as const; + +/** + * Generic CRUD for every ISMS register row (context issues, interested parties, + * requirements, objectives) via a single create / update / delete trio routed by + * the `:register` segment, plus the singleton narrative save. Bodies are read + * from `req.body` and validated by the register registry's zod schemas. + */ +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsRegistersController { + private readonly registry: Record; + + constructor( + contextIssueService: IsmsContextIssueService, + interestedPartyService: IsmsInterestedPartyService, + requirementService: IsmsRequirementService, + objectiveService: IsmsObjectiveService, + private readonly narrativeService: IsmsNarrativeService, + ) { + this.registry = createRegisterRegistry({ + contextIssues: contextIssueService, + interestedParties: interestedPartyService, + requirements: requirementService, + objectives: objectiveService, + }); + } + + private resolve(register: string): RegisterHandler { + const handler = this.registry[register as IsmsRegisterKey]; + if (!handler) { + throw new BadRequestException(`Unknown ISMS register: ${register}`); + } + return handler; + } + + @Post('documents/:id/registers/:register') + @HttpCode(HttpStatus.CREATED) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Create a row in an ISMS register' }) + @ApiConsumes('application/json') + @ApiBody(REGISTER_ROW_BODY) + @ApiOkResponse({ description: 'Register row created' }) + async createRow( + @Param('id') id: string, + @Param('register') register: string, + // Read req.body directly: the global ValidationPipe mangles nested JSON. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).create({ + documentId: id, + organizationId, + data: req.body, + }); + } + + @Patch('registers/:register/:rowId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Update a row in an ISMS register' }) + @ApiConsumes('application/json') + @ApiBody(REGISTER_ROW_BODY) + @ApiOkResponse({ description: 'Register row updated' }) + async updateRow( + @Param('register') register: string, + @Param('rowId') rowId: string, + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).update({ + rowId, + organizationId, + data: req.body, + }); + } + + @Delete('registers/:register/:rowId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Delete a row in an ISMS register' }) + @ApiOkResponse({ description: 'Register row deleted' }) + async deleteRow( + @Param('register') register: string, + @Param('rowId') rowId: string, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).remove({ rowId, organizationId }); + } + + // --- Singleton narrative (4.3 scope, 5.1 leadership) --- + + @Post('documents/:id/narrative') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Save a singleton document narrative' }) + @ApiConsumes('application/json') + @ApiBody(NARRATIVE_BODY) + @ApiOkResponse({ description: 'Narrative saved' }) + async saveNarrative( + @Param('id') id: string, + // Read req.body directly: ValidationPipe with transform mangles nested JSON. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + const body = (req.body ?? {}) as { narrative?: unknown }; + return this.narrativeService.save({ + documentId: id, + organizationId, + narrative: body.narrative, + }); + } +} diff --git a/apps/api/src/isms/isms-requirement.service.spec.ts b/apps/api/src/isms/isms-requirement.service.spec.ts new file mode 100644 index 0000000000..be92393645 --- /dev/null +++ b/apps/api/src/isms/isms-requirement.service.spec.ts @@ -0,0 +1,224 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsRequirementService } from './isms-requirement.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsInterestedParty: { findFirst: jest.fn() }, + ismsInterestedPartyRequirement: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsRequirementService', () => { + let service: IsmsRequirementService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsRequirementService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { partyName: 'Customers', requirement: 'r', treatment: 't' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual requirement at the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ position: 0 }); + ( + mockDb.ismsInterestedPartyRequirement.create as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + + await service.create(args); + + expect(mockDb.ismsInterestedPartyRequirement.create).toHaveBeenCalledWith( + { + data: expect.objectContaining({ + source: 'manual', + position: 1, + interestedPartyId: null, + }), + }, + ); + }); + + it('rejects a party that does not belong to the document', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { + partyName: 'Customers', + requirement: 'r', + treatment: 't', + interestedPartyId: 'ip_other', + }, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('links a party that belongs to the document', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ position: 0 }); + ( + mockDb.ismsInterestedPartyRequirement.create as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + + await service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { + partyName: 'Customers', + requirement: 'r', + treatment: 't', + interestedPartyId: 'ip_1', + }, + }); + + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_1', documentId: 'doc_1' }, + select: { id: true }, + }); + expect( + mockDb.ismsInterestedPartyRequirement.create, + ).toHaveBeenCalledWith({ + data: expect.objectContaining({ interestedPartyId: 'ip_1' }), + }); + }); + }); + + describe('update', () => { + const args = { + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { treatment: 'updated' }, + }; + + it('throws NotFoundException when not in org', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', source: 'derived' }); + ( + mockDb.ismsInterestedPartyRequirement.update as jest.Mock + ).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsInterestedPartyRequirement.update).toHaveBeenCalledWith( + { + where: { id: 'ipr_1' }, + data: expect.objectContaining({ + treatment: 'updated', + source: 'manual', + }), + }, + ); + }); + + it('rejects relinking to a party from another document', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', documentId: 'doc_1' }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.update({ + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { interestedPartyId: 'ip_other' }, + }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_other', documentId: 'doc_1' }, + select: { id: true }, + }); + }); + }); + + describe('remove', () => { + const args = { requirementId: 'ipr_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the requirement', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + ( + mockDb.ismsInterestedPartyRequirement.delete as jest.Mock + ).mockResolvedValue({}); + const result = await service.remove(args); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', documentId: 'doc_1' }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + ( + mockDb.ismsInterestedPartyRequirement.update as jest.Mock + ).mockResolvedValue({}); + + await service.update({ + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { treatment: 'updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-requirement.service.ts b/apps/api/src/isms/isms-requirement.service.ts new file mode 100644 index 0000000000..568219d3bf --- /dev/null +++ b/apps/api/src/isms/isms-requirement.service.ts @@ -0,0 +1,194 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateRequirementInput, + UpdateRequirementInput, +} from './registers/register-registry'; + +/** + * CRUD for the Interested Parties Requirements & ISMS Treatment register (clauses + * 4.2b/c). Derived rows are written by IsmsContextService.generate; this service + * handles manual edits. Editing a derived row flips its source to 'manual'. + */ +@Injectable() +export class IsmsRequirementService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateRequirementInput; + }) { + await this.requireDocument({ documentId, organizationId }); + // Treat empty/whitespace as "no link" so a blank id can't skip validation + // or be persisted as an invalid reference. + const interestedPartyId = dto.interestedPartyId?.trim() || null; + if (interestedPartyId) { + await this.requirePartyInDocument({ interestedPartyId, documentId }); + } + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsInterestedPartyRequirement.create({ + data: { + documentId, + interestedPartyId, + partyName: dto.partyName, + requirement: dto.requirement, + treatment: dto.treatment, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + requirementId, + organizationId, + dto, + }: { + requirementId: string; + organizationId: string; + dto: UpdateRequirementInput; + }) { + const requirement = await this.requireRequirement({ + requirementId, + organizationId, + }); + // undefined = field omitted (leave as-is); empty string = clear the link. + const partyFieldProvided = dto.interestedPartyId !== undefined; + const interestedPartyId = dto.interestedPartyId?.trim() || null; + if (interestedPartyId) { + await this.requirePartyInDocument({ + interestedPartyId, + documentId: requirement.documentId, + }); + } + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: requirement.documentId, + }); + return tx.ismsInterestedPartyRequirement.update({ + where: { id: requirementId }, + data: { + interestedPartyId: partyFieldProvided ? interestedPartyId : undefined, + partyName: dto.partyName ?? undefined, + requirement: dto.requirement ?? undefined, + treatment: dto.treatment ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + requirementId, + organizationId, + }: { + requirementId: string; + organizationId: string; + }) { + const requirement = await this.requireRequirement({ + requirementId, + organizationId, + }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: requirement.documentId, + }); + await tx.ismsInterestedPartyRequirement.delete({ + where: { id: requirementId }, + }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsInterestedPartyRequirement.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + /** Ensures a linked interested party belongs to the same document (and org). */ + private async requirePartyInDocument({ + interestedPartyId, + documentId, + }: { + interestedPartyId: string; + documentId: string; + }) { + const party = await db.ismsInterestedParty.findFirst({ + where: { id: interestedPartyId, documentId }, + select: { id: true }, + }); + if (!party) { + throw new BadRequestException( + 'Interested party does not belong to this document', + ); + } + return party; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireRequirement({ + requirementId, + organizationId, + }: { + requirementId: string; + organizationId: string; + }) { + const requirement = await db.ismsInterestedPartyRequirement.findFirst({ + where: { id: requirementId, document: { organizationId } }, + }); + if (!requirement) { + throw new NotFoundException('Requirement not found'); + } + return requirement; + } +} diff --git a/apps/api/src/isms/isms.controller.permissions.spec.ts b/apps/api/src/isms/isms.controller.permissions.spec.ts new file mode 100644 index 0000000000..145432787d --- /dev/null +++ b/apps/api/src/isms/isms.controller.permissions.spec.ts @@ -0,0 +1,73 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import { IsmsController } from './isms.controller'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('../auth/app-access', () => ({ + resolveRolePermissions: jest.fn(), + permissionsGrant: jest.fn(), +})); +jest.mock('../auth/service-token.config', () => ({ + resolveServiceByName: jest.fn(), +})); +jest.mock('./isms.service', () => ({ + IsmsService: class MockIsmsService {}, +})); +jest.mock('./isms-context.service', () => ({ + IsmsContextService: class MockIsmsContextService {}, +})); +jest.mock('./isms-document-control.service', () => ({ + IsmsDocumentControlService: class MockIsmsDocumentControlService {}, +})); + +describe('IsmsController permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsController) => + reflector.get(PERMISSIONS_KEY, IsmsController.prototype[method]); + + it('gates ensure-setup with evidence:read', () => { + expect(permissionsFor('ensureSetup')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates read endpoints with evidence:read', () => { + expect(permissionsFor('getDocument')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + expect(permissionsFor('drift')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + expect(permissionsFor('exportDocument')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates mutation endpoints with evidence:update', () => { + for (const method of [ + 'generate', + 'addControls', + 'removeControl', + 'submitForApproval', + 'approve', + 'decline', + ] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); +}); diff --git a/apps/api/src/isms/isms.controller.spec.ts b/apps/api/src/isms/isms.controller.spec.ts new file mode 100644 index 0000000000..fde926f23d --- /dev/null +++ b/apps/api/src/isms/isms.controller.spec.ts @@ -0,0 +1,344 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { Response } from 'express'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { resolveRolePermissions, permissionsGrant } from '../auth/app-access'; +import { resolveServiceByName } from '../auth/service-token.config'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { IsmsController } from './isms.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; + +const mockResolveRolePermissions = jest.mocked(resolveRolePermissions); +const mockPermissionsGrant = jest.mocked(permissionsGrant); +const mockResolveServiceByName = jest.mocked(resolveServiceByName); + +/** Build a minimal session-auth AuthContext for ensure-setup tests. */ +const sessionContext = ( + overrides: Partial = {}, +): AuthContextType => ({ + organizationId: 'org_1', + authType: 'session', + isApiKey: false, + isServiceToken: false, + isPlatformAdmin: false, + userRoles: ['auditor'], + ...overrides, +}); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('../auth/app-access', () => ({ + resolveRolePermissions: jest.fn(), + permissionsGrant: jest.fn(), +})); +jest.mock('../auth/service-token.config', () => ({ + resolveServiceByName: jest.fn(), +})); +jest.mock('./isms.service', () => ({ + IsmsService: class MockIsmsService {}, +})); +jest.mock('./isms-context.service', () => ({ + IsmsContextService: class MockIsmsContextService {}, +})); +jest.mock('./isms-document-control.service', () => ({ + IsmsDocumentControlService: class MockIsmsDocumentControlService {}, +})); + +describe('IsmsController', () => { + let controller: IsmsController; + + const mockIsmsService = { + ensureSetup: jest.fn(), + getDocument: jest.fn(), + submitForApproval: jest.fn(), + approve: jest.fn(), + decline: jest.fn(), + }; + const mockContextService = { + generate: jest.fn(), + drift: jest.fn(), + exportDocument: jest.fn(), + }; + const mockDocumentControlService = { + addControls: jest.fn(), + removeControl: jest.fn(), + }; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsController], + providers: [ + { provide: IsmsService, useValue: mockIsmsService }, + { provide: IsmsContextService, useValue: mockContextService }, + { + provide: IsmsDocumentControlService, + useValue: mockDocumentControlService, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsController); + jest.clearAllMocks(); + }); + + it('ensureSetup derives the org from the session, not the body', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['update'] }); + mockPermissionsGrant.mockReturnValue(true); + + const result = await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext(), + ); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + canWrite: true, + }); + expect(result).toEqual({ success: true }); + }); + + it('ensureSetup threads canWrite=true when the caller has evidence:update', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['update'] }); + mockPermissionsGrant.mockReturnValue(true); + + await controller.ensureSetup({ frameworkId: 'fw_1' }, 'org_1', sessionContext()); + + expect(mockResolveRolePermissions).toHaveBeenCalledWith('org_1', ['auditor']); + expect(mockPermissionsGrant).toHaveBeenCalledWith( + { evidence: ['update'] }, + 'evidence', + 'update', + ); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('ensureSetup threads canWrite=false for a read-only caller', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['read'] }); + mockPermissionsGrant.mockReturnValue(false); + + await controller.ensureSetup({ frameworkId: 'fw_1' }, 'org_1', sessionContext()); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: false }), + ); + }); + + it('ensureSetup grants canWrite to platform admins without resolving roles', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ isPlatformAdmin: true }), + ); + + expect(mockResolveRolePermissions).not.toHaveBeenCalled(); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('ensureSetup resolves canWrite from API key scopes', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ + authType: 'api-key', + isApiKey: true, + userRoles: null, + apiKeyScopes: ['evidence:read'], + }), + ); + + expect(mockResolveRolePermissions).not.toHaveBeenCalled(); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: false }), + ); + }); + + it('ensureSetup resolves canWrite from service-token permissions', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveServiceByName.mockReturnValue({ + envVar: 'SERVICE_TOKEN_X', + name: 'X', + permissions: ['evidence:update'], + }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ + authType: 'service', + isServiceToken: true, + serviceName: 'x', + userRoles: null, + }), + ); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('getDocument passes documentId and organizationId', async () => { + mockIsmsService.getDocument.mockResolvedValue({ id: 'doc_1' }); + + await controller.getDocument('doc_1', 'org_1'); + + expect(mockIsmsService.getDocument).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('addControls passes documentId, controlIds and org', async () => { + mockDocumentControlService.addControls.mockResolvedValue({ + message: 'Controls linked', + }); + + await controller.addControls('doc_1', { controlIds: ['ctl_1'] }, 'org_1'); + + expect(mockDocumentControlService.addControls).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1'], + }); + }); + + it('removeControl passes documentId, controlId and org', async () => { + mockDocumentControlService.removeControl.mockResolvedValue({ + message: 'Control unlinked', + }); + + await controller.removeControl('doc_1', 'ctl_1', 'org_1'); + + expect(mockDocumentControlService.removeControl).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + }); + + it('generate delegates to the context service', async () => { + mockContextService.generate.mockResolvedValue({ id: 'doc_1' }); + + await controller.generate('doc_1', 'org_1'); + + expect(mockContextService.generate).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('submitForApproval passes documentId, dto and org', async () => { + const dto = { approverId: 'mem_1' }; + mockIsmsService.submitForApproval.mockResolvedValue({ id: 'doc_1' }); + + await controller.submitForApproval('doc_1', dto, 'org_1'); + + expect(mockIsmsService.submitForApproval).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + }); + + it('approve passes documentId, org and userId', async () => { + mockIsmsService.approve.mockResolvedValue({ id: 'doc_1' }); + + await controller.approve('doc_1', 'org_1', 'usr_1'); + + expect(mockIsmsService.approve).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }); + }); + + it('decline passes documentId, org and userId', async () => { + mockIsmsService.decline.mockResolvedValue({ id: 'doc_1' }); + + await controller.decline('doc_1', 'org_1', 'usr_1'); + + expect(mockIsmsService.decline).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }); + }); + + it('drift delegates to the context service', async () => { + mockContextService.drift.mockResolvedValue({ + isStale: false, + changedSources: [], + }); + + await controller.drift('doc_1', 'org_1'); + + expect(mockContextService.drift).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('exportDocument sets headers and sends the buffer', async () => { + const fileBuffer = Buffer.from('pdf-data'); + mockContextService.exportDocument.mockResolvedValue({ + fileBuffer, + mimeType: 'application/pdf', + filename: 'context-of-the-organization-v1.pdf', + }); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + const dto = { format: 'pdf' as const }; + + await controller.exportDocument('doc_1', dto, 'org_1', res); + + expect(mockContextService.exportDocument).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/pdf', + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="context-of-the-organization-v1.pdf"', + ); + expect(res.send).toHaveBeenCalledWith(fileBuffer); + }); +}); diff --git a/apps/api/src/isms/isms.controller.ts b/apps/api/src/isms/isms.controller.ts new file mode 100644 index 0000000000..0cdbbec885 --- /dev/null +++ b/apps/api/src/isms/isms.controller.ts @@ -0,0 +1,250 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiProduces, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { + AuthContext, + OrganizationId, + UserId, +} from '@/auth/auth-context.decorator'; +import type { AuthContext as AuthContextType } from '@/auth/types'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { resolveRolePermissions, permissionsGrant } from '../auth/app-access'; +import { resolveServiceByName } from '../auth/service-token.config'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; +import { EnsureIsmsSetupDto } from './dto/ensure-isms-setup.dto'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.dto'; +import { LinkIsmsControlsDto } from './dto/link-isms-controls.dto'; + +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsController { + constructor( + private readonly ismsService: IsmsService, + private readonly contextService: IsmsContextService, + private readonly documentControlService: IsmsDocumentControlService, + ) {} + + // Gated at evidence:read so read-only auditors can LIST existing ISMS + // documents, but provisioning only happens when the caller can actually write + // (evidence:update) — resolved below and threaded as `canWrite`. This keeps + // read-only callers from creating rows just by viewing the page. + @Post('ensure-setup') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Ensure ISMS foundational documents exist' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Setup ensured' }) + async ensureSetup( + @Body() dto: EnsureIsmsSetupDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.ismsService.ensureSetup({ + organizationId, + frameworkId: dto.frameworkId, + canWrite: await this.resolveCanWrite(authContext), + }); + } + + /** + * Whether the caller has `evidence:update`, mirroring PermissionGuard's + * precedence (platform admin → API key scopes → service token → roles). Used + * to keep ensure-setup's list path read-only while still letting writers + * provision missing documents. + */ + private async resolveCanWrite(ctx: AuthContextType): Promise { + const RESOURCE = 'evidence'; + const ACTION = 'update'; + if (ctx.isPlatformAdmin) return true; + + if (ctx.isApiKey) { + const scopes = ctx.apiKeyScopes; + // Legacy keys (empty scopes) keep full access until the guard's cutoff; + // the guard already blocks them past the deprecation date. + if (!scopes || scopes.length === 0) return true; + return scopes.includes(`${RESOURCE}:${ACTION}`); + } + + if (ctx.isServiceToken) { + const service = resolveServiceByName(ctx.serviceName); + return service?.permissions.includes(`${RESOURCE}:${ACTION}`) ?? false; + } + + const perms = await resolveRolePermissions( + ctx.organizationId, + ctx.userRoles ?? [], + ); + return permissionsGrant(perms, RESOURCE, ACTION); + } + + @Get('documents/:id') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Get an ISMS document with its latest version' }) + @ApiOkResponse({ description: 'ISMS document' }) + async getDocument( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.getDocument({ documentId: id, organizationId }); + } + + @Post('documents/:id/controls') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Map organization controls to an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Controls linked' }) + async addControls( + @Param('id') id: string, + @Body() dto: LinkIsmsControlsDto, + @OrganizationId() organizationId: string, + ) { + return this.documentControlService.addControls({ + documentId: id, + organizationId, + controlIds: dto.controlIds, + }); + } + + @Delete('documents/:id/controls/:controlId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Remove a control mapping from an ISMS document' }) + @ApiOkResponse({ description: 'Control unlinked' }) + async removeControl( + @Param('id') id: string, + @Param('controlId') controlId: string, + @OrganizationId() organizationId: string, + ) { + return this.documentControlService.removeControl({ + documentId: id, + organizationId, + controlId, + }); + } + + @Post('documents/:id/generate') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Derive Context-of-the-Organization issues' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document with derived issues' }) + async generate( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.contextService.generate({ documentId: id, organizationId }); + } + + @Post('documents/:id/submit-for-approval') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Submit an ISMS document for approval' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document submitted for approval' }) + async submitForApproval( + @Param('id') id: string, + @Body() dto: SubmitIsmsForApprovalDto, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.submitForApproval({ + documentId: id, + organizationId, + dto, + }); + } + + @Post('documents/:id/approve') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Approve an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document approved' }) + async approve( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @UserId() userId: string, + ) { + return this.ismsService.approve({ + documentId: id, + organizationId, + userId, + }); + } + + @Post('documents/:id/decline') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Decline an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document declined' }) + async decline( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @UserId() userId: string, + ) { + return this.ismsService.decline({ documentId: id, organizationId, userId }); + } + + @Get('documents/:id/drift') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Detect drift against the approved snapshot' }) + @ApiOkResponse({ description: 'Drift status' }) + async drift( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.contextService.drift({ documentId: id, organizationId }); + } + + @Post('documents/:id/export') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Export an ISMS document as PDF or DOCX' }) + @ApiConsumes('application/json') + @ApiProduces('application/pdf') + @ApiOkResponse({ description: 'Rendered document' }) + async exportDocument( + @Param('id') id: string, + @Body() dto: ExportIsmsDocumentDto, + @OrganizationId() organizationId: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.contextService.exportDocument({ + documentId: id, + organizationId, + dto, + }); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename}"`, + ); + res.send(result.fileBuffer); + } +} diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts new file mode 100644 index 0000000000..88a134904a --- /dev/null +++ b/apps/api/src/isms/isms.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { IsmsController } from './isms.controller'; +import { IsmsRegistersController } from './isms-registers.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; +import { IsmsProfileController } from './wizard/isms-profile.controller'; +import { IsmsProfileService } from './wizard/isms-profile.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [ + IsmsController, + IsmsRegistersController, + IsmsProfileController, + ], + providers: [ + IsmsService, + IsmsContextService, + IsmsContextIssueService, + IsmsDocumentControlService, + IsmsInterestedPartyService, + IsmsRequirementService, + IsmsObjectiveService, + IsmsNarrativeService, + IsmsProfileService, + ], + exports: [ + IsmsService, + IsmsContextService, + IsmsContextIssueService, + IsmsDocumentControlService, + IsmsInterestedPartyService, + IsmsRequirementService, + IsmsObjectiveService, + IsmsNarrativeService, + IsmsProfileService, + ], +}) +export class IsmsModule {} diff --git a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts new file mode 100644 index 0000000000..ce1dd0a85e --- /dev/null +++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts @@ -0,0 +1,101 @@ +import { db } from '@db'; +import { IsmsService } from './isms.service'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + frameworkEditorIsmsDocumentTemplate: { findMany: jest.fn() }, + ismsDocument: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { createMany: jest.fn() }, + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); + +/** Convenience accessor for the first createMany call's `data` array. */ +const createManyData = () => + (mockDb.ismsDocument.createMany as jest.Mock).mock.calls[0][0].data; + +describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no templates seeded)', () => { + let service: IsmsService; + const dto = { organizationId: 'org_1', frameworkId: 'fw_1', canWrite: true }; + const mockTemplates = mockDb.frameworkEditorIsmsDocumentTemplate + .findMany as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsDocument.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + mockTemplates.mockResolvedValue([]); + }); + + it('creates only missing document types and maps requirements', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [{ id: 'req_41', name: '4.1 Context', identifier: '4.1' }], + }); + // One existing type so only the other five are created. + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) // existing-types probe + .mockResolvedValueOnce([]) // created lookup + .mockResolvedValueOnce([ + { + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: 'req_41', + }, + ]); // final list + + const result = await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); + expect(createManyData()).toHaveLength(5); + // Definition-derived docs carry no templateId. + expect(createManyData()[0].templateId).toBeNull(); + expect(result.success).toBe(true); + expect(result.documents[0]).toEqual({ + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: 'req_41', + hasApprovedVersion: false, + }); + }); + + it('leaves requirementId null when no clause matches', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw_1', requirements: [] }); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()).toHaveLength(6); + expect(createManyData()[0].requirementId).toBeNull(); + }); +}); diff --git a/apps/api/src/isms/isms.service.lifecycle.spec.ts b/apps/api/src/isms/isms.service.lifecycle.spec.ts new file mode 100644 index 0000000000..41ea994e3a --- /dev/null +++ b/apps/api/src/isms/isms.service.lifecycle.spec.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { IsmsService } from './isms.service'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { + findFirst: jest.fn(), + update: jest.fn(), + }, + member: { findFirst: jest.fn() }, + $transaction: jest.fn(), + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); +const mockRunDerivation = jest.mocked(runDerivation); + +describe('IsmsService document lifecycle', () => { + let service: IsmsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + }); + + describe('getDocument', () => { + it('throws NotFoundException when not found / wrong org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.getDocument({ documentId: 'doc_1', organizationId: 'org_1' }), + ).rejects.toThrow(NotFoundException); + }); + + it('returns the document scoped by org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + const result = await service.getDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + expect(result).toEqual({ id: 'doc_1' }); + expect(mockDb.ismsDocument.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'doc_1', organizationId: 'org_1' }, + }), + ); + }); + + it('includes control links with the linked control id and name', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + controlLinks: [], + }); + + await service.getDocument({ documentId: 'doc_1', organizationId: 'org_1' }); + + const callArgs = (mockDb.ismsDocument.findFirst as jest.Mock).mock + .calls[0][0]; + expect(callArgs.include.controlLinks.select).toEqual({ + id: true, + controlId: true, + control: { select: { id: true, name: true } }, + }); + }); + }); + + describe('submitForApproval', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { approverId: 'mem_1' }, + }; + + it('throws NotFoundException when approver not in org', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.submitForApproval(args)).rejects.toThrow( + NotFoundException, + ); + }); + + it('sets approver and needs_review status', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + }); + + await service.submitForApproval(args); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: expect.objectContaining({ + approverId: 'mem_1', + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }), + }); + }); + }); + + describe('approve', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.approve(args)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when document is not pending approval', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'draft', + approverId: 'mem_1', + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(BadRequestException); + }); + + it('throws ForbiddenException when no approver is assigned', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: null, + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when not the assigned approver', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_other', + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(ForbiddenException); + }); + + it('snapshots data and marks approved', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_1', + frameworkId: 'fw_1', + type: 'context_of_organization', + }) + .mockResolvedValueOnce({ id: 'doc_1', status: 'approved' }); + mockCollect.mockResolvedValue({ + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 1, + subProcessorCount: 0, + vendorsByCategory: {}, + subProcessorNames: [], + infraVendorNames: [], + memberCount: 1, + membersByDepartment: {}, + deviceCount: 0, + riskCount: 0, + highRiskCount: 0, + hasTrainingProgram: false, + wizardAnswers: {}, + partiesFingerprint: '', + }); + const tx = { + ismsDocument: { update: jest.fn().mockResolvedValue({}) }, + }; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + + await service.approve(args); + + expect(mockCollect).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + // Re-derives inside the transaction from the same snapshot, so the + // persisted rows and the snapshot baseline come from one pass. + expect(mockRunDerivation).toHaveBeenCalledWith({ + tx, + type: 'context_of_organization', + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: expect.objectContaining({ organizationName: 'Acme' }), + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalled(); + expect(tx.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: expect.objectContaining({ + status: 'approved', + declinedAt: null, + }), + }); + }); + }); + + describe('decline', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.decline(args)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when document is not pending approval', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'approved', + approverId: 'mem_1', + }); + await expect(service.decline(args)).rejects.toThrow(BadRequestException); + }); + + it('throws ForbiddenException when not the assigned approver', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_other', + }); + await expect(service.decline(args)).rejects.toThrow(ForbiddenException); + }); + + it('sets declined status and declinedAt', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_1', + }); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'declined', + }); + + await service.decline(args); + + const call = (mockDb.ismsDocument.update as jest.Mock).mock.calls[0][0]; + expect(call.data.status).toBe('declined'); + expect(call.data.declinedAt).toBeInstanceOf(Date); + }); + }); +}); diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts new file mode 100644 index 0000000000..d8a1f34410 --- /dev/null +++ b/apps/api/src/isms/isms.service.spec.ts @@ -0,0 +1,305 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsService } from './isms.service'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + frameworkEditorIsmsDocumentTemplate: { findMany: jest.fn() }, + ismsDocument: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { createMany: jest.fn() }, + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); + +/** Convenience accessor for the first createMany call's `data` array. */ +const createManyData = () => + (mockDb.ismsDocument.createMany as jest.Mock).mock.calls[0][0].data; + +describe('IsmsService ensureSetup', () => { + let service: IsmsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsDocument.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + }); + + describe('ensureSetup', () => { + const dto = { organizationId: 'org_1', frameworkId: 'fw_1', canWrite: true }; + + const mockTemplates = mockDb.frameworkEditorIsmsDocumentTemplate + .findMany as jest.Mock; + + it('throws NotFoundException when framework not found', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + await expect(service.ensureSetup(dto)).rejects.toThrow(NotFoundException); + }); + + describe('read-only callers (canWrite: false)', () => { + it('never writes — only lists the existing documents', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [], + }); + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValueOnce([ + { + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: null, + }, + ]); + + const result = await service.ensureSetup({ ...dto, canWrite: false }); + + // No provisioning at all: no template resolution, no creates. + expect(mockTemplates).not.toHaveBeenCalled(); + expect(mockDb.ismsDocument.createMany).not.toHaveBeenCalled(); + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + // The findMany that ran was the list query, not a provisioning probe. + expect(mockDb.ismsDocument.findMany).toHaveBeenCalledTimes(1); + expect(result.documents).toHaveLength(1); + }); + }); + + describe('template-driven (templates seeded)', () => { + beforeEach(() => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [ + { id: 'req_41', name: '4.1 Context', identifier: '4.1' }, + { id: 'req_62', name: '6.2 Objectives', identifier: '6.2' }, + ], + }); + }); + + it('creates docs from templates with templateId set', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) // existing-types probe + .mockResolvedValueOnce([]) // created lookup + .mockResolvedValueOnce([]); // final list + + await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); + expect(createManyData()).toHaveLength(1); + expect(createManyData()[0]).toMatchObject({ + type: 'context_of_organization', + title: 'Context of the Organization', + templateId: 'tpl_ctx', + requirementId: 'req_41', // resolved via clause fallback "4.1" + }); + }); + + it('prefers an explicit framework requirement link over clause match', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [ + { frameworkId: 'fw_1', requirementId: 'req_custom' }, + ], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()[0].requirementId).toBe('req_custom'); + expect(createManyData()[0].templateId).toBe('tpl_ctx'); + }); + + it('falls back to clause match when no link exists for the framework', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_obj', + documentType: 'objectives_plan', + name: 'Objectives and Plan', + clause: '6.2', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()[0].requirementId).toBe('req_62'); + }); + + it('skips templates whose document type already exists', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + { + id: 'tpl_obj', + documentType: 'objectives_plan', + name: 'Objectives and Plan', + clause: '6.2', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()).toHaveLength(1); + expect(createManyData()[0].type).toBe('objectives_plan'); + }); + + it('auto-derives org control links from the template control links', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [ + { controlTemplateId: 'ct_1' }, + { controlTemplateId: 'ct_2' }, + ], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) // existing-types probe + .mockResolvedValueOnce([ + { id: 'doc_new', type: 'context_of_organization' }, + ]) // created lookup + .mockResolvedValueOnce([]); // final list + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + { id: 'ctl_2' }, + ]); + + await service.ensureSetup(dto); + + expect(mockDb.control.findMany).toHaveBeenCalledWith({ + where: { + organizationId: 'org_1', + controlTemplateId: { in: ['ct_1', 'ct_2'] }, + }, + select: { id: true }, + }); + expect(mockDb.ismsDocumentControlLink.createMany).toHaveBeenCalledWith({ + data: [ + { ismsDocumentId: 'doc_new', controlId: 'ctl_1' }, + { ismsDocumentId: 'doc_new', controlId: 'ctl_2' }, + ], + skipDuplicates: true, + }); + }); + + it('skips control derivation when the template has no control links', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'doc_new', type: 'context_of_organization' }, + ]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + }); + + it('preserves existing links on re-run by skipping created types', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [{ controlTemplateId: 'ct_1' }], + }, + ]); + // Document already exists, so no create and no control derivation runs; + // any manual control links the org added are left untouched. + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).not.toHaveBeenCalled(); + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts new file mode 100644 index 0000000000..4d92c33d3f --- /dev/null +++ b/apps/api/src/isms/isms.service.ts @@ -0,0 +1,298 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-plan'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +/** + * ISMS foundational document lifecycle: setup, retrieval and sign-off. Context + * derivation/drift/export live in IsmsContextService and issue CRUD in + * IsmsContextIssueService. + */ +@Injectable() +export class IsmsService { + /** + * List the org's ISMS documents, provisioning missing ones first only when the + * caller can write (`evidence:update`). Read-only callers never trigger writes. + */ + async ensureSetup({ + organizationId, + frameworkId, + canWrite, + }: { + organizationId: string; + frameworkId: string; + canWrite: boolean; + }) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + include: { + requirements: { select: { id: true, name: true, identifier: true } }, + }, + }); + + if (!framework) { + throw new NotFoundException('Framework not found'); + } + + if (canWrite) { + await this.provisionMissingDocuments({ + organizationId, + frameworkId, + requirements: framework.requirements, + }); + } + + const documents = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + }); + + return { + success: true, + documents: documents.map((doc) => ({ + id: doc.id, + type: doc.type, + status: doc.status, + requirementId: doc.requirementId, + hasApprovedVersion: doc.status === 'approved', + })), + }; + } + + /** + * Create any missing ISMS documents for the (org, framework), then derive + * control links for just the newly-created types so manual links on existing + * documents stay untouched. `createMany` + `skipDuplicates` makes this safe + * under concurrent calls — the unique (org, framework, type) constraint + * absorbs the race, mirroring the idempotent ensureProfile pattern. + */ + private async provisionMissingDocuments({ + organizationId, + frameworkId, + requirements, + }: { + organizationId: string; + frameworkId: string; + requirements: Array<{ id: string; name: string; identifier: string }>; + }) { + const existing = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + select: { type: true }, + }); + const existingTypes = new Set(existing.map((doc) => doc.type)); + + const plans = await resolveDocumentPlans({ frameworkId, requirements }); + const missingPlans = plans.filter((plan) => !existingTypes.has(plan.type)); + if (missingPlans.length === 0) return; + + await db.ismsDocument.createMany({ + data: missingPlans.map((plan) => ({ + organizationId, + frameworkId, + type: plan.type, + title: plan.title, + status: 'draft', + requirementId: plan.requirementId, + templateId: plan.templateId, + })), + skipDuplicates: true, + }); + + const created = await db.ismsDocument.findMany({ + where: { + organizationId, + frameworkId, + type: { in: missingPlans.map((plan) => plan.type) }, + }, + select: { id: true, type: true }, + }); + const controlTemplatesByType = new Map( + missingPlans.map((plan) => [plan.type, plan.controlTemplateIds]), + ); + + for (const doc of created) { + await deriveControlLinks({ + documentId: doc.id, + organizationId, + controlTemplateIds: controlTemplatesByType.get(doc.type) ?? [], + }); + } + } + + async getDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { + versions: { where: { isLatest: true }, take: 1 }, + contextIssues: { orderBy: { position: 'asc' } }, + interestedParties: { orderBy: { position: 'asc' } }, + interestedPartyRequirements: { orderBy: { position: 'asc' } }, + objectives: { orderBy: { position: 'asc' } }, + controlLinks: { + select: { + id: true, + controlId: true, + control: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + return document; + } + + async submitForApproval({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: SubmitIsmsForApprovalDto; + }) { + const approver = await db.member.findFirst({ + where: { id: dto.approverId, organizationId, deactivated: false }, + }); + if (!approver) { + throw new NotFoundException('Approver not found in organization'); + } + + await this.requireDocument({ documentId, organizationId }); + + return db.ismsDocument.update({ + where: { id: documentId }, + data: { + approverId: dto.approverId, + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }, + }); + } + + async approve({ + documentId, + organizationId, + userId, + }: { + documentId: string; + organizationId: string; + userId: string; + }) { + const member = await this.requireMember({ organizationId, userId }); + const document = await this.requireDocument({ documentId, organizationId }); + this.assertPendingApprovalBy({ document, member }); + + const snapshot = await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + }); + + await db.$transaction(async (tx) => { + // Re-derive in the same transaction so the persisted rows and the snapshot + // baseline come from one pass (otherwise the approved content can drift). + await runDerivation({ + tx, + type: document.type, + documentId, + organizationId, + frameworkId: document.frameworkId, + data: snapshot, + }); + await upsertLatestSnapshotVersion({ tx, documentId, snapshot }); + await tx.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'approved', approvedAt: new Date(), declinedAt: null }, + }); + }); + + return this.getDocument({ documentId, organizationId }); + } + + async decline({ + documentId, + organizationId, + userId, + }: { + documentId: string; + organizationId: string; + userId: string; + }) { + const member = await this.requireMember({ organizationId, userId }); + const document = await this.requireDocument({ documentId, organizationId }); + this.assertPendingApprovalBy({ document, member }); + + return db.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'declined', declinedAt: new Date() }, + }); + } + + /** + * Guard shared by approve/decline: the document must be awaiting review and the + * acting member must be its assigned approver. + */ + private assertPendingApprovalBy({ + document, + member, + }: { + document: { status: string; approverId: string | null }; + member: { id: string }; + }) { + if (document.status !== 'needs_review') { + throw new BadRequestException('Document is not pending approval'); + } + if (!document.approverId || document.approverId !== member.id) { + throw new ForbiddenException('Document is not pending your approval'); + } + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireMember({ + organizationId, + userId, + }: { + organizationId: string; + userId: string; + }) { + const member = await db.member.findFirst({ + where: { organizationId, userId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Member not found'); + } + return member; + } +} diff --git a/apps/api/src/isms/registers/register-registry.spec.ts b/apps/api/src/isms/registers/register-registry.spec.ts new file mode 100644 index 0000000000..ce00c0ff46 --- /dev/null +++ b/apps/api/src/isms/registers/register-registry.spec.ts @@ -0,0 +1,223 @@ +import { BadRequestException } from '@nestjs/common'; +import type { IsmsContextIssueService } from '../isms-context-issue.service'; +import type { IsmsInterestedPartyService } from '../isms-interested-party.service'; +import type { IsmsObjectiveService } from '../isms-objective.service'; +import type { IsmsRequirementService } from '../isms-requirement.service'; +import { + createRegisterRegistry, + ISMS_REGISTER_KEYS, + type RegisterServices, +} from './register-registry'; + +describe('createRegisterRegistry', () => { + const contextIssues = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + const interestedParties = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const requirements = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + const objectives = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + + const services = { + contextIssues, + interestedParties, + requirements, + objectives, + } as unknown as RegisterServices; + + const registry = createRegisterRegistry(services); + + beforeEach(() => jest.clearAllMocks()); + + it('exposes a handler for every register key', () => { + expect(Object.keys(registry).sort()).toEqual([...ISMS_REGISTER_KEYS].sort()); + }); + + describe('context-issues', () => { + it('create dispatches with documentId and parsed dto, passing category through', async () => { + const data = { + kind: 'internal', + category: 'Strategic', + description: 'd', + effect: 'e', + }; + await registry['context-issues'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(contextIssues.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with issueId', async () => { + await registry['context-issues'].update({ + rowId: 'row1', + organizationId: 'org_1', + data: { description: 'x' }, + }); + expect(contextIssues.update).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + dto: { description: 'x' }, + }); + }); + + it('remove dispatches with issueId', async () => { + await registry['context-issues'].remove({ + rowId: 'row1', + organizationId: 'org_1', + }); + expect(contextIssues.remove).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + }); + }); + + it('create throws BadRequestException when description is missing', () => { + expect(() => + registry['context-issues'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data: { kind: 'internal', effect: 'e' }, + }), + ).toThrow(BadRequestException); + expect(contextIssues.create).not.toHaveBeenCalled(); + }); + }); + + describe('interested-parties', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { name: 'Customers', category: 'Customer', needsExpectations: 'n' }; + await registry['interested-parties'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(interestedParties.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with partyId', async () => { + await registry['interested-parties'].update({ + rowId: 'ip_1', + organizationId: 'org_1', + data: { name: 'X' }, + }); + expect(interestedParties.update).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'X' }, + }); + }); + + it('remove dispatches with partyId', async () => { + await registry['interested-parties'].remove({ + rowId: 'ip_1', + organizationId: 'org_1', + }); + expect(interestedParties.remove).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + }); + }); + }); + + describe('requirements', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { partyName: 'C', requirement: 'r', treatment: 't' }; + await registry.requirements.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(requirements.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with requirementId', async () => { + await registry.requirements.update({ + rowId: 'req_1', + organizationId: 'org_1', + data: { requirement: 'r2' }, + }); + expect(requirements.update).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + dto: { requirement: 'r2' }, + }); + }); + + it('remove dispatches with requirementId', async () => { + await registry.requirements.remove({ + rowId: 'req_1', + organizationId: 'org_1', + }); + expect(requirements.remove).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + }); + }); + }); + + describe('objectives', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { objective: 'o' }; + await registry.objectives.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(objectives.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with objectiveId', async () => { + await registry.objectives.update({ + rowId: 'obj_1', + organizationId: 'org_1', + data: { status: 'met' }, + }); + expect(objectives.update).toHaveBeenCalledWith({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' }, + }); + }); + + it('remove dispatches with objectiveId', async () => { + await registry.objectives.remove({ + rowId: 'obj_1', + organizationId: 'org_1', + }); + expect(objectives.remove).toHaveBeenCalledWith({ + objectiveId: 'obj_1', + organizationId: 'org_1', + }); + }); + + it('create throws BadRequestException when status is not in the enum', () => { + expect(() => + registry.objectives.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data: { objective: 'o', status: 'bogus' }, + }), + ).toThrow(BadRequestException); + expect(objectives.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts new file mode 100644 index 0000000000..7af3da2eb2 --- /dev/null +++ b/apps/api/src/isms/registers/register-registry.ts @@ -0,0 +1,210 @@ +import { BadRequestException } from '@nestjs/common'; +import { z } from 'zod'; +import type { IsmsContextIssueService } from '../isms-context-issue.service'; +import type { IsmsInterestedPartyService } from '../isms-interested-party.service'; +import type { IsmsObjectiveService } from '../isms-objective.service'; +import type { IsmsRequirementService } from '../isms-requirement.service'; + +/** + * One generic dispatch for every ISMS register row (context issues, interested + * parties, requirements, objectives). The controller exposes three endpoints — + * create / update / delete — and routes by the `:register` path segment to the + * matching service here. Bodies are validated with zod off `req.body` (the + * global ValidationPipe mangles nested JSON), per-register schemas mirroring the + * original DTOs. This replaces the 13 near-identical per-register endpoints. + */ + +const position = z.number().int().min(0).optional(); +const OBJECTIVE_STATUS = ['not_started', 'on_track', 'at_risk', 'met'] as const; + +const schemas = { + contextIssueCreate: z.object({ + kind: z.enum(['internal', 'external']), + category: z.string().optional(), + description: z.string(), + effect: z.string(), + position, + }), + contextIssueUpdate: z.object({ + kind: z.enum(['internal', 'external']).optional(), + category: z.string().optional(), + description: z.string().optional(), + effect: z.string().optional(), + position, + }), + interestedPartyCreate: z.object({ + name: z.string(), + category: z.string(), + needsExpectations: z.string(), + position, + }), + interestedPartyUpdate: z.object({ + name: z.string().optional(), + category: z.string().optional(), + needsExpectations: z.string().optional(), + position, + }), + requirementCreate: z.object({ + interestedPartyId: z.string().optional(), + partyName: z.string(), + requirement: z.string(), + treatment: z.string(), + position, + }), + requirementUpdate: z.object({ + interestedPartyId: z.string().optional(), + partyName: z.string().optional(), + requirement: z.string().optional(), + treatment: z.string().optional(), + position, + }), + objectiveCreate: z.object({ + objective: z.string(), + target: z.string().optional(), + ownerMemberId: z.string().optional(), + cadence: z.string().optional(), + plan: z.string().optional(), + measurementMethod: z.string().optional(), + status: z.enum(OBJECTIVE_STATUS).optional(), + position, + }), + objectiveUpdate: z.object({ + objective: z.string().optional(), + target: z.string().optional(), + ownerMemberId: z.string().optional(), + cadence: z.string().optional(), + plan: z.string().optional(), + measurementMethod: z.string().optional(), + status: z.enum(OBJECTIVE_STATUS).optional(), + position, + }), +} as const; + +// Inferred input types — the single source of truth for register row shapes. +// Service method signatures use these directly; the per-register DTO classes were +// removed because they only duplicated these schemas. +export type CreateContextIssueInput = z.infer; +export type UpdateContextIssueInput = z.infer; +export type CreateInterestedPartyInput = z.infer< + typeof schemas.interestedPartyCreate +>; +export type UpdateInterestedPartyInput = z.infer< + typeof schemas.interestedPartyUpdate +>; +export type CreateRequirementInput = z.infer; +export type UpdateRequirementInput = z.infer; +export type CreateObjectiveInput = z.infer; +export type UpdateObjectiveInput = z.infer; + +export const ISMS_REGISTER_KEYS = [ + 'context-issues', + 'interested-parties', + 'requirements', + 'objectives', +] as const; + +export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number]; + +export interface RegisterHandler { + create(args: { + documentId: string; + organizationId: string; + data: unknown; + }): Promise; + update(args: { + rowId: string; + organizationId: string; + data: unknown; + }): Promise; + remove(args: { rowId: string; organizationId: string }): Promise; +} + +function parse(schema: z.ZodType, data: unknown): T { + const result = schema.safeParse(data ?? {}); + if (!result.success) { + const detail = result.error.issues + .map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`) + .join('; '); + throw new BadRequestException(`Invalid register row: ${detail}`); + } + return result.data; +} + +export interface RegisterServices { + contextIssues: IsmsContextIssueService; + interestedParties: IsmsInterestedPartyService; + requirements: IsmsRequirementService; + objectives: IsmsObjectiveService; +} + +/** Build the register → handler map from the injected per-register services. */ +export function createRegisterRegistry( + services: RegisterServices, +): Record { + return { + 'context-issues': { + create: ({ documentId, organizationId, data }) => + services.contextIssues.create({ + documentId, + organizationId, + dto: parse(schemas.contextIssueCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.contextIssues.update({ + issueId: rowId, + organizationId, + dto: parse(schemas.contextIssueUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.contextIssues.remove({ issueId: rowId, organizationId }), + }, + 'interested-parties': { + create: ({ documentId, organizationId, data }) => + services.interestedParties.create({ + documentId, + organizationId, + dto: parse(schemas.interestedPartyCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.interestedParties.update({ + partyId: rowId, + organizationId, + dto: parse(schemas.interestedPartyUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.interestedParties.remove({ partyId: rowId, organizationId }), + }, + requirements: { + create: ({ documentId, organizationId, data }) => + services.requirements.create({ + documentId, + organizationId, + dto: parse(schemas.requirementCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.requirements.update({ + requirementId: rowId, + organizationId, + dto: parse(schemas.requirementUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.requirements.remove({ requirementId: rowId, organizationId }), + }, + objectives: { + create: ({ documentId, organizationId, data }) => + services.objectives.create({ + documentId, + organizationId, + dto: parse(schemas.objectiveCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.objectives.update({ + objectiveId: rowId, + organizationId, + dto: parse(schemas.objectiveUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.objectives.remove({ objectiveId: rowId, organizationId }), + }, + }; +} diff --git a/apps/api/src/isms/utils/approval.ts b/apps/api/src/isms/utils/approval.ts new file mode 100644 index 0000000000..186bfcae04 --- /dev/null +++ b/apps/api/src/isms/utils/approval.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@db'; + +/** + * Editing an approved ISMS document invalidates its sign-off: revert it to draft + * so the change must be re-approved (mirrors policy approval invalidation). Only + * touches the document when it is currently `approved`; a no-op otherwise. Must + * run inside the same transaction as the content write so a failed write does not + * leave the document reverted to draft without the new content. + */ +export async function invalidateApprovalIfNeeded({ + tx, + documentId, +}: { + tx: Prisma.TransactionClient; + documentId: string; +}): Promise { + const document = await tx.ismsDocument.findUnique({ + where: { id: documentId }, + select: { status: true }, + }); + + if (document?.status !== 'approved') { + return; + } + + await tx.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); +} diff --git a/apps/api/src/isms/utils/context-derivation.spec.ts b/apps/api/src/isms/utils/context-derivation.spec.ts new file mode 100644 index 0000000000..4bf5a78244 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.spec.ts @@ -0,0 +1,125 @@ +import { + deriveContextIssues, + EXTERNAL_ISSUE_CATEGORIES, + INTERNAL_ISSUE_CATEGORIES, + type ContextDerivationInput, +} from './context-derivation'; + +const baseInput: ContextDerivationInput = { + frameworkNames: ['ISO 27001'], + vendorCount: 5, + subProcessorCount: 2, + vendorsByCategory: { cloud: 2, software_as_a_service: 3 }, + memberCount: 12, + membersByDepartment: { it: 4, hr: 2, none: 6 }, + deviceCount: 8, +}; + +describe('deriveContextIssues', () => { + it('produces both internal and external issues with provenance', () => { + const issues = deriveContextIssues(baseInput); + + expect(issues.length).toBeGreaterThanOrEqual(4); + expect(issues.length).toBeLessThanOrEqual(8); + expect(issues.some((i) => i.kind === 'external')).toBe(true); + expect(issues.some((i) => i.kind === 'internal')).toBe(true); + expect(issues.every((i) => i.source === 'derived')).toBe(true); + expect(issues.every((i) => i.derivedFrom.length > 0)).toBe(true); + expect(issues.every((i) => i.effect.length > 0)).toBe(true); + }); + + it('assigns sequential positions', () => { + const issues = deriveContextIssues(baseInput); + issues.forEach((issue, index) => expect(issue.position).toBe(index)); + }); + + it('emits one external framework issue per active framework', () => { + const issues = deriveContextIssues({ + ...baseInput, + frameworkNames: ['ISO 27001', 'SOC 2'], + }); + const frameworkIssues = issues.filter((i) => + i.derivedFrom.startsWith('framework:'), + ); + expect(frameworkIssues).toHaveLength(2); + expect(frameworkIssues.map((i) => i.derivedFrom)).toEqual([ + 'framework:ISO 27001', + 'framework:SOC 2', + ]); + }); + + it('includes a sub-processor data-protection issue when sub-processors exist', () => { + const issues = deriveContextIssues(baseInput); + expect(issues.some((i) => i.derivedFrom === 'subprocessors')).toBe(true); + }); + + it('emits a remote-work issue when there are no devices', () => { + const issues = deriveContextIssues({ ...baseInput, deviceCount: 0 }); + const deviceIssue = issues.find((i) => i.derivedFrom === 'devices'); + expect(deviceIssue?.description).toContain('remote'); + }); + + it('is deterministic for identical input', () => { + expect(deriveContextIssues(baseInput)).toEqual( + deriveContextIssues(baseInput), + ); + }); +}); + +describe('deriveContextIssues — categories', () => { + it('tags framework issues as Regulatory & Legal', () => { + const issue = deriveContextIssues(baseInput).find((i) => + i.derivedFrom.startsWith('framework:'), + ); + expect(issue?.category).toBe('Regulatory & Legal'); + }); + + it('tags the vendor issue as Technological', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.kind === 'external' && i.derivedFrom === 'vendors', + ); + expect(issue?.category).toBe('Technological'); + }); + + it('tags the sub-processor issue as Regulatory & Legal', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.derivedFrom === 'subprocessors', + ); + expect(issue?.category).toBe('Regulatory & Legal'); + }); + + it('tags the workforce issue as Governance & Structure', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.derivedFrom === 'members', + ); + expect(issue?.category).toBe('Governance & Structure'); + }); + + it('tags cloud-footprint and device issues as Capabilities & Resources', () => { + const issues = deriveContextIssues(baseInput); + const cloud = issues.find( + (i) => i.kind === 'internal' && i.derivedFrom === 'vendors', + ); + const device = issues.find((i) => i.derivedFrom === 'devices'); + expect(cloud?.category).toBe('Capabilities & Resources'); + expect(device?.category).toBe('Capabilities & Resources'); + }); + + it('tags the remote-work fallback issue as Capabilities & Resources', () => { + const issue = deriveContextIssues({ ...baseInput, deviceCount: 0 }).find( + (i) => i.derivedFrom === 'devices', + ); + expect(issue?.description).toContain('remote'); + expect(issue?.category).toBe('Capabilities & Resources'); + }); + + it('only uses categories from the published taxonomy', () => { + const valid = new Set([ + ...EXTERNAL_ISSUE_CATEGORIES, + ...INTERNAL_ISSUE_CATEGORIES, + ]); + for (const issue of deriveContextIssues(baseInput)) { + expect(valid.has(issue.category)).toBe(true); + } + }); +}); diff --git a/apps/api/src/isms/utils/context-derivation.ts b/apps/api/src/isms/utils/context-derivation.ts new file mode 100644 index 0000000000..95d71ac378 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.ts @@ -0,0 +1,181 @@ +import type { IsmsContextIssueKind, IsmsContextSource } from '@db'; + +/** + * Deterministic derivation of "Context of the Organization" (ISO 27001 clause 4.1) + * internal & external issues from platform data. No AI — the same inputs always + * produce the same set of issues, so drift detection is a pure comparison of the + * captured snapshot against a freshly recomputed snapshot. + */ + +/** Raw platform data the derivation reads. Captured verbatim as the sourceSnapshot. */ +export interface ContextDerivationInput { + /** Names of the frameworks the organization is actively pursuing. */ + frameworkNames: string[]; + /** Total third-party vendors tracked in the org. */ + vendorCount: number; + /** Vendors flagged as sub-processors. */ + subProcessorCount: number; + /** Vendor counts keyed by category (e.g. cloud, software_as_a_service). */ + vendorsByCategory: Record; + /** Total active (non-deactivated) workforce members. */ + memberCount: number; + /** Member counts keyed by department (e.g. it, hr, gov). */ + membersByDepartment: Record; + /** Total managed endpoints/devices. */ + deviceCount: number; +} + +/** + * The ISO 27001 clause 4.1 category taxonomy. Auditors expect external and + * internal issues grouped under these headings; the export renders one table + * row per issue with its category, and the editor offers these as options. + */ +export const EXTERNAL_ISSUE_CATEGORIES = [ + 'Regulatory & Legal', + 'Market & Economic', + 'Technological', + 'Social & Cultural', +] as const; + +export const INTERNAL_ISSUE_CATEGORIES = [ + 'Governance & Structure', + 'Strategy & Objectives', + 'Capabilities & Resources', + 'Culture & Values', +] as const; + +export type ExternalIssueCategory = (typeof EXTERNAL_ISSUE_CATEGORIES)[number]; +export type InternalIssueCategory = (typeof INTERNAL_ISSUE_CATEGORIES)[number]; + +/** A single derived issue, ready to be written as an IsmsContextIssue row. */ +export interface DerivedContextIssue { + kind: IsmsContextIssueKind; + category: ExternalIssueCategory | InternalIssueCategory; + description: string; + effect: string; + source: IsmsContextSource; + derivedFrom: string; + position: number; +} + +function buildExternalIssues( + input: ContextDerivationInput, +): Array> { + const issues: Array> = []; + + for (const name of input.frameworkNames) { + issues.push({ + kind: 'external', + category: 'Regulatory & Legal', + description: `Compliance obligations arising from the ${name} framework that the organization is pursuing.`, + effect: `The ISMS must implement and evidence controls sufficient to satisfy ${name}, shaping ISMS objectives and the audit scope.`, + source: 'derived', + derivedFrom: `framework:${name}`, + }); + } + + if (input.vendorCount > 0) { + issues.push({ + kind: 'external', + category: 'Technological', + description: `Reliance on ${input.vendorCount} third-party vendor${input.vendorCount === 1 ? '' : 's'}${input.subProcessorCount > 0 ? `, of which ${input.subProcessorCount} act as sub-processor${input.subProcessorCount === 1 ? '' : 's'}` : ''}.`, + effect: + 'Supplier risk and data-sharing arrangements extend the ISMS boundary and require vendor due diligence and ongoing monitoring.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (input.subProcessorCount > 0) { + issues.push({ + kind: 'external', + category: 'Regulatory & Legal', + description: `Personal or customer data is processed by ${input.subProcessorCount} sub-processor${input.subProcessorCount === 1 ? '' : 's'}, creating regulatory and data-protection obligations.`, + effect: + 'The ISMS must address data-protection, breach-notification and contractual safeguards for data handled outside the organization.', + source: 'derived', + derivedFrom: 'subprocessors', + }); + } + + return issues; +} + +function buildInternalIssues( + input: ContextDerivationInput, +): Array> { + const issues: Array> = []; + + if (input.memberCount > 0) { + const departments = Object.keys(input.membersByDepartment).filter( + (dept) => dept !== 'none' && input.membersByDepartment[dept] > 0, + ); + const departmentSummary = + departments.length > 0 ? ` spanning ${departments.join(', ')}` : ''; + issues.push({ + kind: 'internal', + category: 'Governance & Structure', + description: `A workforce of ${input.memberCount} member${input.memberCount === 1 ? '' : 's'}${departmentSummary}.`, + effect: + 'Headcount and organizational structure determine security awareness, segregation of duties and access-management needs within the ISMS.', + source: 'derived', + derivedFrom: 'members', + }); + } + + const cloudVendors = + (input.vendorsByCategory.cloud ?? 0) + + (input.vendorsByCategory.infrastructure ?? 0) + + (input.vendorsByCategory.software_as_a_service ?? 0); + if (cloudVendors > 0) { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: `A cloud-centric technology footprint built on ${cloudVendors} infrastructure and SaaS provider${cloudVendors === 1 ? '' : 's'}.`, + effect: + 'The chosen architecture defines where data resides and which technical controls (encryption, access control, logging) the ISMS must enforce.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (input.deviceCount > 0) { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: `${input.deviceCount} managed endpoint${input.deviceCount === 1 ? '' : 's'} used by the workforce.`, + effect: + 'Endpoint posture (encryption, patching, configuration) is a core ISMS objective and drives device-management controls.', + source: 'derived', + derivedFrom: 'devices', + }); + } else { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: + 'A predominantly remote working model with limited centrally-managed hardware.', + effect: + 'Remote work shifts ISMS emphasis toward identity, endpoint and SaaS controls rather than physical security.', + source: 'derived', + derivedFrom: 'devices', + }); + } + + return issues; +} + +/** + * Produce a lean, deterministic set of internal/external context issues from the + * captured platform data. Position is assigned sequentially so the register has a + * stable order. + */ +export function deriveContextIssues( + input: ContextDerivationInput, +): DerivedContextIssue[] { + const ordered = [ + ...buildExternalIssues(input), + ...buildInternalIssues(input), + ]; + return ordered.map((issue, index) => ({ ...issue, position: index })); +} diff --git a/apps/api/src/isms/utils/document-lock.ts b/apps/api/src/isms/utils/document-lock.ts new file mode 100644 index 0000000000..22fef4f602 --- /dev/null +++ b/apps/api/src/isms/utils/document-lock.ts @@ -0,0 +1,15 @@ +import type { Prisma } from '@db'; + +/** + * Serialize register-row position allocation for a single document. The Postgres + * transaction-scoped advisory lock (keyed on the document id) is held until the + * surrounding transaction commits, so two concurrent creates can't both read the + * same max(position) and persist duplicate ordering keys. Call this inside the + * create transaction, before computing the next position. + */ +export async function lockDocumentForPositions( + tx: Prisma.TransactionClient, + documentId: string, +): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${documentId}, 0))`; +} diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts new file mode 100644 index 0000000000..dd479691ec --- /dev/null +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -0,0 +1,74 @@ +import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; + +describe('ISMS_TYPE_DEFINITIONS', () => { + it('defines all six foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(6); + const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); + expect(types).toEqual( + expect.arrayContaining([ + 'context_of_organization', + 'interested_parties_register', + 'interested_parties_requirements', + 'isms_scope', + 'leadership_commitment', + 'objectives_plan', + ]), + ); + }); + + it('maps 4.2 to both interested-parties documents', () => { + const clause42 = ISMS_TYPE_DEFINITIONS.filter((d) => d.clause === '4.2'); + expect(clause42.map((d) => d.type)).toEqual([ + 'interested_parties_register', + 'interested_parties_requirements', + ]); + }); +}); + +describe('matchRequirementId', () => { + const requirements = [ + { + id: 'req-41', + name: '4.1 Understanding the organization', + identifier: '4.1', + }, + { id: 'req-42', name: '4.2 Interested parties', identifier: '4.2' }, + { id: 'req-141', name: '14.1 Security in development', identifier: '14.1' }, + ]; + + it('matches an exact clause identifier', () => { + expect(matchRequirementId({ clause: '4.1', requirements })).toBe('req-41'); + }); + + it('does not confuse 4.1 with 14.1', () => { + expect(matchRequirementId({ clause: '4.1', requirements })).not.toBe( + 'req-141', + ); + }); + + it('matches via the name when identifier is empty', () => { + expect( + matchRequirementId({ + clause: '5.1', + requirements: [ + { id: 'req-51', name: '5.1 Leadership', identifier: '' }, + ], + }), + ).toBe('req-51'); + }); + + it('returns null when no requirement matches', () => { + expect(matchRequirementId({ clause: '6.2', requirements })).toBeNull(); + }); + + it('does not match a clause that is a prefix of another (4.1 vs 4.11)', () => { + expect( + matchRequirementId({ + clause: '4.1', + requirements: [ + { id: 'req-411', name: '4.11 Other', identifier: '4.11' }, + ], + }), + ).toBeNull(); + }); +}); diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts new file mode 100644 index 0000000000..a4179bdbd5 --- /dev/null +++ b/apps/api/src/isms/utils/document-types.ts @@ -0,0 +1,87 @@ +import type { IsmsDocumentType } from '@db'; + +/** ISO 27001 clause each foundational document type satisfies. */ +export interface IsmsTypeDefinition { + type: IsmsDocumentType; + /** Clause number used to match the FrameworkEditorRequirement (e.g. "4.1"). */ + clause: string; + title: string; + /** Short summary of what the document covers (used as the template description). */ + description: string; +} + +/** + * The full set of ISMS foundational documents and the single source the + * framework-editor template seed derives from (see packages/db .../seed.ts). + * ensure-setup falls back to this list when no templates exist in the DB. + * Several types share a clause (4.2 → register + requirements). + */ +export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ + { + type: 'context_of_organization', + clause: '4.1', + title: 'Context of the Organization', + description: + 'Internal and external issues relevant to the ISMS and their effect on its intended outcomes (ISO 27001 clause 4.1).', + }, + { + type: 'interested_parties_register', + clause: '4.2', + title: 'Interested Parties Register', + description: + 'The interested parties relevant to the ISMS together with their needs and expectations (ISO 27001 clause 4.2).', + }, + { + type: 'interested_parties_requirements', + clause: '4.2', + title: 'Interested Parties Requirements', + description: + 'The requirements of interested parties and how the ISMS addresses them (ISO 27001 clause 4.2).', + }, + { + type: 'isms_scope', + clause: '4.3', + title: 'ISMS Scope', + description: + 'The boundaries and applicability of the ISMS, including the interfaces and dependencies considered (ISO 27001 clause 4.3).', + }, + { + type: 'leadership_commitment', + clause: '5.1', + title: 'Leadership and Commitment', + description: + 'Evidence of top management leadership and commitment to the ISMS (ISO 27001 clause 5.1).', + }, + { + type: 'objectives_plan', + clause: '6.2', + title: 'Information Security Objectives and Plan', + description: + 'Measurable information security objectives and the plan to achieve them (ISO 27001 clause 6.2).', + }, +]; + +/** + * Find the requirement whose name or identifier starts with the given clause + * number. Matches "4.1", "4.1.1", "4.1 Understanding..." but not "14.1". + */ +export function matchRequirementId({ + clause, + requirements, +}: { + clause: string; + requirements: Array<{ id: string; name: string; identifier: string }>; +}): string | null { + const matches = (value: string | null | undefined): boolean => { + if (!value) return false; + const trimmed = value.trim(); + if (trimmed === clause) return true; + // Must be followed by a separator so "4.1" does not match "4.11". + return new RegExp(`^${clause.replace('.', '\\.')}(\\D|$)`).test(trimmed); + }; + + const found = requirements.find( + (req) => matches(req.identifier) || matches(req.name), + ); + return found?.id ?? null; +} diff --git a/apps/api/src/isms/utils/docx-renderer.spec.ts b/apps/api/src/isms/utils/docx-renderer.spec.ts new file mode 100644 index 0000000000..bc155c039f --- /dev/null +++ b/apps/api/src/isms/utils/docx-renderer.spec.ts @@ -0,0 +1,82 @@ +import { renderIsmsDocx } from './docx-renderer'; +import type { + IsmsExportMetadata, + IsmsExportSection, +} from './export-shared'; + +// Exercises the REAL renderer (no mock). docx exposes a CommonJS `require` +// entry, so jest resolves it without transforming node_modules. + +const metadata: IsmsExportMetadata = { + title: 'Context of the Organization', + clause: '4.1', + documentCode: 'ACME-ISMS-001', + standardLabel: 'ISO/IEC 27001:2022', + frameworkName: 'ISO 27001', + version: 2, + preparedBy: 'Comp AI', + owner: 'CISO', + status: 'approved', + approverName: 'Jane Approver', + approvedAt: '2026-01-15', + declinedAt: null, + classification: 'Internal', + nextReview: 'Annual, or on material change', + issueDate: '2026-01-01', + organizationName: 'Acme Corp', + primaryColor: '#004D3D', +}; + +const sections: IsmsExportSection[] = [ + { + heading: '1. Purpose', + intro: 'This document establishes the context of the organization.', + paragraphs: [ + { label: 'Effect: ', text: 'Bounds the ISMS.' }, + { text: 'A plain paragraph with no label.', bold: true }, + ], + }, + { + heading: '2. Organization overview', + keyValues: [ + { label: 'Legal name', value: 'Acme Corp' }, + { label: 'Sector', value: 'Software' }, + ], + }, + { + heading: '3. Interested parties', + table: { + headers: ['Party', 'Category', 'Needs & expectations'], + rows: [ + ['Customers', 'External', 'Confidentiality of data'], + ['Regulators', 'External', 'Demonstrable compliance'], + ], + }, + }, + { + heading: '4. Intended outcomes', + bullets: ['Protect confidentiality', 'Maintain availability'], + }, +]; + +const PK_MAGIC = [0x50, 0x4b]; + +describe('renderIsmsDocx', () => { + it('renders a non-empty DOCX (ZIP) buffer covering every content block', async () => { + const buffer = await renderIsmsDocx({ sections, metadata }); + + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.length).toBeGreaterThan(0); + // A .docx is a ZIP archive: it must start with the PK local-file header. + expect(buffer[0]).toBe(PK_MAGIC[0]); + expect(buffer[1]).toBe(PK_MAGIC[1]); + }); + + it('does not throw and still emits a valid ZIP for an empty sections array', async () => { + const buffer = await renderIsmsDocx({ sections: [], metadata }); + + expect(buffer.length).toBeGreaterThan(0); + expect(buffer[0]).toBe(PK_MAGIC[0]); + expect(buffer[1]).toBe(PK_MAGIC[1]); + }); +}); diff --git a/apps/api/src/isms/utils/docx-renderer.ts b/apps/api/src/isms/utils/docx-renderer.ts new file mode 100644 index 0000000000..7e4a361772 --- /dev/null +++ b/apps/api/src/isms/utils/docx-renderer.ts @@ -0,0 +1,298 @@ +import { + AlignmentType, + BorderStyle, + Document, + Footer, + PageNumber, + Packer, + Paragraph, + ShadingType, + Table, + TableCell, + TableRow, + TextRun, + WidthType, +} from 'docx'; +import { + metadataRows, + type IsmsExportMetadata, + type IsmsExportParagraph, + type IsmsExportSection, + type IsmsExportTable, + type IsmsKeyValue, +} from './export-shared'; + +const DEFAULT_ACCENT = '004D3D'; +const INK = '212121'; +const MUTED = '6E6E6E'; +const HAIRLINE = 'DFDFDF'; +const ZEBRA = 'F7F7F7'; +const WHITE = 'FFFFFF'; + +function normalizeHexColor(hex: string | null): string { + if (!hex) return DEFAULT_ACCENT; + const clean = hex.replace('#', '').trim(); + return /^[0-9a-fA-F]{6}$/.test(clean) ? clean.toUpperCase() : DEFAULT_ACCENT; +} + +const thin = { style: BorderStyle.SINGLE, size: 4, color: HAIRLINE }; +const TABLE_BORDERS = { + top: thin, + bottom: thin, + left: thin, + right: thin, + insideHorizontal: thin, + insideVertical: thin, +}; + +function shaded(fill: string) { + return { type: ShadingType.CLEAR, fill, color: 'auto' }; +} + +function cell({ + text, + bold, + color, + fill, + width, +}: { + text: string; + bold?: boolean; + color?: string; + fill?: string; + width?: number; +}): TableCell { + return new TableCell({ + width: width ? { size: width, type: WidthType.DXA } : undefined, + shading: fill ? shaded(fill) : undefined, + margins: { top: 60, bottom: 60, left: 90, right: 90 }, + children: [ + new Paragraph({ + children: [new TextRun({ text, bold, color: color ?? INK })], + }), + ], + }); +} + +/** A 2-column label/value table (metadata block + organization overview). */ +function keyValueTable(rows: IsmsKeyValue[]): Table { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: [2600, 6426], + borders: TABLE_BORDERS, + rows: rows.map( + (row) => + new TableRow({ + children: [ + cell({ + text: row.label, + bold: true, + color: MUTED, + fill: ZEBRA, + width: 2600, + }), + cell({ text: row.value, width: 6426 }), + ], + }), + ), + }); +} + +/** A bordered data table with a shaded accent header row. */ +function dataTable({ + table, + accent, +}: { + table: IsmsExportTable; + accent: string; +}): Table { + const widths = + table.headers.length === 3 ? [1900, 3000, 4126] : undefined; + const headerRow = new TableRow({ + tableHeader: true, + children: table.headers.map( + (header, index) => + new TableCell({ + width: widths ? { size: widths[index], type: WidthType.DXA } : undefined, + shading: shaded(accent), + margins: { top: 60, bottom: 60, left: 90, right: 90 }, + children: [ + new Paragraph({ + children: [new TextRun({ text: header, bold: true, color: WHITE })], + }), + ], + }), + ), + }); + const bodyRows = table.rows.map( + (row) => + new TableRow({ + children: row.map((value, index) => + cell({ + text: value, + bold: widths ? index === 0 : false, + width: widths ? widths[index] : undefined, + }), + ), + }), + ); + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: widths, + borders: TABLE_BORDERS, + rows: [headerRow, ...bodyRows], + }); +} + +function paragraphRuns(paragraph: IsmsExportParagraph): TextRun[] { + const runs: TextRun[] = []; + if (paragraph.label) { + runs.push(new TextRun({ text: paragraph.label, bold: true })); + } + runs.push(new TextRun({ text: paragraph.text, bold: paragraph.bold })); + return runs; +} + +function sectionElements({ + section, + accent, +}: { + section: IsmsExportSection; + accent: string; +}): Array { + const elements: Array = [ + new Paragraph({ + spacing: { before: 280, after: 120 }, + children: [ + new TextRun({ text: section.heading, bold: true, color: accent, size: 26 }), + ], + }), + ]; + + const hasContent = + Boolean(section.intro) || + Boolean(section.paragraphs?.length) || + Boolean(section.bullets?.length) || + Boolean(section.keyValues?.length) || + Boolean(section.table && section.table.rows.length); + + if (!hasContent) { + elements.push( + new Paragraph({ + children: [ + new TextRun({ text: section.emptyText ?? 'No entries recorded.' }), + ], + }), + ); + return elements; + } + + if (section.intro) { + elements.push( + new Paragraph({ spacing: { after: 80 }, children: [new TextRun(section.intro)] }), + ); + } + for (const paragraph of section.paragraphs ?? []) { + elements.push( + new Paragraph({ spacing: { after: 60 }, children: paragraphRuns(paragraph) }), + ); + } + for (const bullet of section.bullets ?? []) { + elements.push( + new Paragraph({ bullet: { level: 0 }, children: [new TextRun(bullet)] }), + ); + } + if (section.keyValues?.length) elements.push(keyValueTable(section.keyValues)); + if (section.table && section.table.rows.length) { + elements.push(dataTable({ table: section.table, accent })); + } + + return elements; +} + +function coverBlock(metadata: IsmsExportMetadata): Paragraph[] { + const center = AlignmentType.CENTER; + const block: Paragraph[] = []; + if (metadata.organizationName) { + block.push( + new Paragraph({ + alignment: center, + spacing: { before: 480, after: 80 }, + children: [ + new TextRun({ text: metadata.organizationName, bold: true, size: 26, color: INK }), + ], + }), + ); + } + block.push( + new Paragraph({ + alignment: center, + spacing: { after: 160 }, + children: [new TextRun({ text: metadata.standardLabel, size: 22, color: MUTED })], + }), + new Paragraph({ + alignment: center, + spacing: { after: 60 }, + children: [new TextRun({ text: metadata.title, bold: true, size: 44, color: metadata.primaryColor ? normalizeHexColor(metadata.primaryColor) : DEFAULT_ACCENT })], + }), + new Paragraph({ + alignment: center, + spacing: { after: 320 }, + children: [new TextRun({ text: `Clause ${metadata.clause}`, size: 22, color: MUTED })], + }), + ); + return block; +} + +function pageFooter(metadata: IsmsExportMetadata): Footer { + const left = [metadata.organizationName, metadata.classification] + .filter(Boolean) + .join(' · '); + return new Footer({ + children: [ + new Paragraph({ + tabStops: [{ type: 'right', position: 9026 }], + children: [ + new TextRun({ text: left, size: 16, color: MUTED }), + new TextRun({ text: `\t${metadata.documentCode} · Page `, size: 16, color: MUTED }), + new TextRun({ children: [PageNumber.CURRENT], size: 16, color: MUTED }), + new TextRun({ text: ' of ', size: 16, color: MUTED }), + new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 16, color: MUTED }), + ], + }), + ], + }); +} + +/** + * Render an ISMS document to a polished DOCX matching the PDF: a centred cover + * block, a metadata table, numbered sections, bullet lists, a key/value + * overview and bordered data tables with a shaded accent header, plus a footer + * with the org, classification and page numbers. + */ +export async function renderIsmsDocx({ + sections, + metadata, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; +}): Promise { + const accent = normalizeHexColor(metadata.primaryColor); + + const body: Array = [ + ...coverBlock(metadata), + keyValueTable(metadataRows(metadata)), + ...sections.flatMap((section) => sectionElements({ section, accent })), + ]; + + const doc = new Document({ + sections: [ + { + footers: { default: pageFooter(metadata) }, + children: body, + }, + ], + }); + + return Packer.toBuffer(doc); +} diff --git a/apps/api/src/isms/utils/ensure-setup-plan.ts b/apps/api/src/isms/utils/ensure-setup-plan.ts new file mode 100644 index 0000000000..e79a7385b2 --- /dev/null +++ b/apps/api/src/isms/utils/ensure-setup-plan.ts @@ -0,0 +1,95 @@ +import { db } from '@db'; +import type { IsmsDocumentType } from '@db'; +import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; + +export interface IsmsDocumentPlan { + type: IsmsDocumentType; + title: string; + requirementId: string | null; + templateId: string | null; + controlTemplateIds: string[]; +} + +/** + * Build one create-plan per ISMS document type. Template-driven when the + * FrameworkEditorIsmsDocumentTemplate rows are seeded; the requirement comes + * from the framework-scoped link if present, otherwise from clause matching. + * Falls back to ISMS_TYPE_DEFINITIONS (no templates) so unseeded DBs still + * work — those plans carry a null templateId and no control links. + */ +export async function resolveDocumentPlans({ + frameworkId, + requirements, +}: { + frameworkId: string; + requirements: Array<{ id: string; name: string; identifier: string }>; +}): Promise { + const templates = await db.frameworkEditorIsmsDocumentTemplate.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { + // Order so requirementLinks[0] is a deterministic pick across runs. + requirementLinks: { + where: { frameworkId }, + orderBy: [{ requirementId: 'asc' }, { id: 'asc' }], + }, + controlLinks: { + where: { frameworkId }, + select: { controlTemplateId: true }, + }, + }, + }); + + if (templates.length === 0) { + return ISMS_TYPE_DEFINITIONS.map((def) => ({ + type: def.type, + title: def.title, + templateId: null, + controlTemplateIds: [], + requirementId: matchRequirementId({ clause: def.clause, requirements }), + })); + } + + return templates.map((template) => ({ + type: template.documentType, + title: template.name, + templateId: template.id, + controlTemplateIds: template.controlLinks.map( + (link) => link.controlTemplateId, + ), + requirementId: + template.requirementLinks[0]?.requirementId ?? + matchRequirementId({ clause: template.clause, requirements }), + })); +} + +/** + * Best-effort: turn a template's framework-scoped control template links into + * org-level IsmsDocumentControlLink rows by resolving the org's Controls that + * were instantiated from those control templates. Idempotent (skipDuplicates) + * and silent when nothing resolves, so re-runs preserve existing links. + */ +export async function deriveControlLinks({ + documentId, + organizationId, + controlTemplateIds, +}: { + documentId: string; + organizationId: string; + controlTemplateIds: string[]; +}): Promise { + if (controlTemplateIds.length === 0) return; + + const controls = await db.control.findMany({ + where: { organizationId, controlTemplateId: { in: controlTemplateIds } }, + select: { id: true }, + }); + if (controls.length === 0) return; + + await db.ismsDocumentControlLink.createMany({ + data: controls.map((control) => ({ + ismsDocumentId: documentId, + controlId: control.id, + })), + skipDuplicates: true, + }); +} diff --git a/apps/api/src/isms/utils/export-generator.spec.ts b/apps/api/src/isms/utils/export-generator.spec.ts new file mode 100644 index 0000000000..8eb3cf5afe --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.spec.ts @@ -0,0 +1,117 @@ +import { + generateIsmsExportFile, + type IsmsExportMetadata, + type IsmsExportSection, +} from './export-generator'; +import { renderIsmsDocx } from './docx-renderer'; + +// docx is ESM-only; the renderer is exercised separately and mocked here so the +// dispatch logic stays unit-testable without transforming node_modules. +jest.mock('./docx-renderer', () => ({ + renderIsmsDocx: jest.fn(), +})); + +const mockRenderDocx = jest.mocked(renderIsmsDocx); + +const metadata: IsmsExportMetadata = { + title: 'Context of the Organization', + clause: '4.1', + documentCode: 'ACME-ISMS-001', + standardLabel: 'ISO/IEC 27001:2022', + frameworkName: 'ISO 27001', + version: 2, + preparedBy: 'Comp AI', + owner: 'Comp AI', + status: 'approved', + approverName: 'Jane Doe', + approvedAt: new Date('2026-05-01T00:00:00.000Z'), + declinedAt: null, + classification: 'Internal', + nextReview: 'Annual, or on material change', + issueDate: new Date('2026-01-01'), + organizationName: 'Acme Inc', + primaryColor: '#123456', +}; + +const paragraphSections: IsmsExportSection[] = [ + { + heading: 'External issues', + paragraphs: [ + { text: '1. Pursuing ISO 27001', bold: true }, + { label: 'Effect: ', text: 'Shapes scope' }, + ], + }, + { + heading: 'Internal issues', + paragraphs: [{ text: '1. 12 workforce members', bold: true }], + }, +]; + +const tableSections: IsmsExportSection[] = [ + { + heading: 'Interested Parties', + emptyText: 'No interested parties recorded.', + table: { + headers: ['Interested party', 'Category', 'Needs & expectations'], + rows: [['Customers', 'Customer', 'Confidentiality of their data']], + }, + }, +]; + +describe('generateIsmsExportFile', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders a real PDF buffer for format=pdf', async () => { + const result = await generateIsmsExportFile({ + sections: paragraphSections, + metadata, + format: 'pdf', + }); + + expect(result.mimeType).toBe('application/pdf'); + expect(result.filename).toBe('context-of-the-organization-v2.pdf'); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + expect(result.fileBuffer.length).toBeGreaterThan(0); + expect(result.fileBuffer.subarray(0, 4).toString()).toBe('%PDF'); + expect(mockRenderDocx).not.toHaveBeenCalled(); + }); + + it('renders a PDF buffer for table-based sections', async () => { + const result = await generateIsmsExportFile({ + sections: tableSections, + metadata, + format: 'pdf', + }); + expect(result.fileBuffer.subarray(0, 4).toString()).toBe('%PDF'); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); + + it('delegates to the docx renderer for format=docx', async () => { + mockRenderDocx.mockResolvedValue(Buffer.from('docx-bytes')); + + const result = await generateIsmsExportFile({ + sections: paragraphSections, + metadata, + format: 'docx', + }); + + expect(mockRenderDocx).toHaveBeenCalledWith({ + sections: paragraphSections, + metadata, + }); + expect(result.mimeType).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + expect(result.filename).toBe('context-of-the-organization-v2.docx'); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + }); + + it('handles an empty section set without throwing', async () => { + const result = await generateIsmsExportFile({ + sections: [], + metadata, + format: 'pdf', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/isms/utils/export-generator.ts b/apps/api/src/isms/utils/export-generator.ts new file mode 100644 index 0000000000..e43ca195c0 --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.ts @@ -0,0 +1,50 @@ +import { renderIsmsDocx } from './docx-renderer'; +import { renderIsmsPdf } from './pdf-renderer'; +import { + DOCX_MIME_TYPE, + type IsmsExportFormat, + type IsmsExportMetadata, + type IsmsExportResult, + type IsmsExportSection, +} from './export-shared'; + +export type { + IsmsExportFormat, + IsmsExportIssue, + IsmsExportMetadata, + IsmsExportResult, + IsmsExportSection, +} from './export-shared'; + +export async function generateIsmsExportFile({ + sections, + metadata, + format, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; + format: IsmsExportFormat; +}): Promise { + const baseName = `${sanitizeName(metadata.title)}-v${metadata.version}`; + + if (format === 'docx') { + return { + fileBuffer: await renderIsmsDocx({ sections, metadata }), + mimeType: DOCX_MIME_TYPE, + filename: `${baseName}.docx`, + }; + } + + return { + fileBuffer: renderIsmsPdf({ sections, metadata }), + mimeType: 'application/pdf', + filename: `${baseName}.pdf`, + }; +} + +function sanitizeName(name: string): string { + return (name || 'isms-document') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} diff --git a/apps/api/src/isms/utils/export-metadata.ts b/apps/api/src/isms/utils/export-metadata.ts new file mode 100644 index 0000000000..71e9438eb7 --- /dev/null +++ b/apps/api/src/isms/utils/export-metadata.ts @@ -0,0 +1,83 @@ +import type { IsmsDocumentType } from '@db'; +import { ISMS_TYPE_DEFINITIONS } from './document-types'; +import { standardLabel, type IsmsExportMetadata } from './export-shared'; + +const DEFAULT_CLASSIFICATION = 'Internal'; +const DEFAULT_NEXT_REVIEW = 'Annual, or on material change'; +const DEFAULT_OWNER = 'Security & Privacy Owner'; + +/** A short org code for the document ID, e.g. "Comp AI" -> "CA", "Acme" -> "ACME". */ +function orgCode(name: string | null): string { + const cleaned = (name ?? '').replace(/[^A-Za-z0-9 ]/g, ' ').trim(); + if (!cleaned) return 'ORG'; + const words = cleaned.split(/\s+/); + if (words.length === 1) return words[0].slice(0, 4).toUpperCase(); + return words + .map((word) => word[0]) + .join('') + .slice(0, 4) + .toUpperCase(); +} + +/** 1-based document number within the ISMS pack (Context of the Organization = 001). */ +function documentNumber(type: IsmsDocumentType): string { + const index = ISMS_TYPE_DEFINITIONS.findIndex((def) => def.type === type); + return String((index < 0 ? 0 : index) + 1).padStart(3, '0'); +} + +function clauseFor(type: IsmsDocumentType): string { + return ISMS_TYPE_DEFINITIONS.find((def) => def.type === type)?.clause ?? ''; +} + +/** + * Build the full export metadata (cover block + metadata table) from the stored + * document and organization. Fields the platform does not yet capture + * (classification, review cadence, owner) fall back to ISO-sensible defaults. + */ +export function buildExportMetadata({ + type, + title, + frameworkName, + version, + status, + preparedBy, + owner, + approverName, + approvedAt, + declinedAt, + organizationName, + primaryColor, +}: { + type: IsmsDocumentType; + title: string; + frameworkName: string; + version: number; + status: string | null; + preparedBy: string | null; + owner: string | null; + approverName: string | null; + approvedAt: Date | null; + declinedAt: Date | null; + organizationName: string | null; + primaryColor: string | null; +}): IsmsExportMetadata { + return { + title, + clause: clauseFor(type), + documentCode: `${orgCode(organizationName)}-ISMS-${documentNumber(type)}`, + standardLabel: standardLabel(frameworkName), + frameworkName, + version, + preparedBy, + owner: owner || preparedBy || DEFAULT_OWNER, + status, + approverName, + approvedAt, + declinedAt, + classification: DEFAULT_CLASSIFICATION, + nextReview: DEFAULT_NEXT_REVIEW, + issueDate: approvedAt ?? new Date(), + organizationName, + primaryColor, + }; +} diff --git a/apps/api/src/isms/utils/export-shared.ts b/apps/api/src/isms/utils/export-shared.ts new file mode 100644 index 0000000000..9f9c2b2ced --- /dev/null +++ b/apps/api/src/isms/utils/export-shared.ts @@ -0,0 +1,161 @@ +import type { IsmsContextIssueKind } from '@db'; + +export type IsmsExportFormat = 'pdf' | 'docx'; + +export interface IsmsExportIssue { + kind: IsmsContextIssueKind; + description: string; + effect: string; +} + +export interface IsmsExportMetadata { + title: string; + /** ISO clause this document satisfies, e.g. "4.1". */ + clause: string; + /** Short human document code, e.g. "CAI-ISMS-001". */ + documentCode: string; + /** Formal standard label for the cover, e.g. "ISO/IEC 27001:2022". */ + standardLabel: string; + frameworkName: string; + version: number; + preparedBy: string | null; + /** The role/person accountable for the document. */ + owner: string | null; + status: string | null; + approverName: string | null; + approvedAt: Date | string | null; + declinedAt: Date | string | null; + /** Information classification, e.g. "Internal". */ + classification: string; + /** Review cadence sentence, e.g. "Annual, or on material change". */ + nextReview: string; + /** Effective/issue date shown in the metadata table. */ + issueDate: Date | string | null; + organizationName: string | null; + primaryColor: string | null; +} + +export interface IsmsExportResult { + fileBuffer: Buffer; + mimeType: string; + filename: string; +} + +/** A label/value pair — used by the metadata table and key/value overview. */ +export interface IsmsKeyValue { + label: string; + value: string; +} + +/** + * A heading plus any combination of content blocks, rendered in this order: + * intro → paragraphs → bullets → key/value table → data table. The unit of + * every export; both the PDF and DOCX renderers consume the same shape. + */ +export interface IsmsExportSection { + heading: string; + /** Optional lead-in paragraph rendered directly under the heading. */ + intro?: string; + /** Free-text paragraphs. */ + paragraphs?: IsmsExportParagraph[]; + /** Bullet-list items. */ + bullets?: string[]; + /** Label/value pairs rendered as a 2-column overview table. */ + keyValues?: IsmsKeyValue[]; + /** Tabular content (registers + the category issue tables render here). */ + table?: IsmsExportTable; + /** Shown (instead of any content) when the section is empty. */ + emptyText?: string; +} + +export interface IsmsExportParagraph { + /** Optional bold lead-in label, e.g. "Effect: ". */ + label?: string; + text: string; + /** Render the whole paragraph bold (used for numbered list titles). */ + bold?: boolean; +} + +export interface IsmsExportTable { + headers: string[]; + rows: string[][]; +} + +export const DOCX_MIME_TYPE = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** Map a framework name to its formal standard label for the cover page. */ +const STANDARD_LABELS: Record = { + 'ISO 27001': 'ISO/IEC 27001:2022', + 'ISO 27001:2022': 'ISO/IEC 27001:2022', + 'ISO 27001:2013': 'ISO/IEC 27001:2013', + 'SOC 2': 'SOC 2', + GDPR: 'GDPR', + HIPAA: 'HIPAA', + 'PCI DSS': 'PCI DSS', +}; + +export function standardLabel(frameworkName: string): string { + return STANDARD_LABELS[frameworkName] ?? frameworkName; +} + +/** Format a date as YYYY-MM-DD (deterministic; matches the reference document). */ +export function formatExportDate(value: Date | string | null): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toISOString().slice(0, 10); +} + +function humanStatus(status: string | null): string { + switch (status) { + case 'approved': + return 'Approved'; + case 'needs_review': + return 'Pending approval'; + case 'declined': + return 'Declined'; + case 'draft': + return 'Draft'; + default: + return status ? status : 'Draft'; + } +} + +/** "v2 · APPROVED" — the version cell of the metadata table. */ +export function versionLabel(metadata: IsmsExportMetadata): string { + return `v${metadata.version} · ${humanStatus(metadata.status).toUpperCase()}`; +} + +/** Human-readable approval status used in the metadata table. */ +export function approvalLine(metadata: IsmsExportMetadata): string { + // Declined wins over a stale approvedAt: a document can carry an approvedAt + // from a prior cycle yet currently be declined. + if (metadata.status === 'declined' || metadata.declinedAt) { + return `Declined on ${formatExportDate(metadata.declinedAt)}`; + } + if (metadata.approvedAt) { + return `Approved on ${formatExportDate(metadata.approvedAt)}`; + } + if (metadata.status === 'needs_review') return 'Pending approval'; + return 'Not approved'; +} + +/** The key/value rows of the cover metadata table (shared by PDF + DOCX). */ +export function metadataRows(metadata: IsmsExportMetadata): IsmsKeyValue[] { + return [ + { label: 'Document ID', value: metadata.documentCode }, + { label: 'Document title', value: metadata.title }, + { + label: 'Clause reference', + value: `${metadata.standardLabel}, Clause ${metadata.clause}`, + }, + { label: 'Version', value: versionLabel(metadata) }, + { label: 'Issue date', value: formatExportDate(metadata.issueDate) }, + { label: 'Owner', value: metadata.owner || metadata.preparedBy || 'Comp AI' }, + { label: 'Approver', value: metadata.approverName || '—' }, + { label: 'Approval status', value: approvalLine(metadata) }, + { label: 'Classification', value: metadata.classification }, + { label: 'Next review', value: metadata.nextReview }, + ]; +} diff --git a/apps/api/src/isms/utils/pdf-renderer.ts b/apps/api/src/isms/utils/pdf-renderer.ts new file mode 100644 index 0000000000..b7bc8cc64f --- /dev/null +++ b/apps/api/src/isms/utils/pdf-renderer.ts @@ -0,0 +1,269 @@ +import { jsPDF } from 'jspdf'; +import { autoTable } from 'jspdf-autotable'; +import { + metadataRows, + type IsmsExportMetadata, + type IsmsExportSection, + type IsmsKeyValue, + type IsmsExportTable, +} from './export-shared'; + +type Rgb = [number, number, number]; + +const INK: Rgb = [33, 33, 33]; +const MUTED: Rgb = [110, 110, 110]; +const HAIRLINE: Rgb = [223, 223, 223]; +const ZEBRA: Rgb = [247, 247, 247]; + +/** jsPDF instance once jspdf-autotable has attached its result accessor. */ +interface JsPdfWithAutoTable extends jsPDF { + lastAutoTable?: { finalY: number }; +} + +function accentColor(hex: string | null): Rgb { + const fallback: Rgb = [0, 77, 61]; + if (!hex) return fallback; + const clean = hex.replace('#', ''); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return fallback; + return [r, g, b]; +} + +/** + * Render an ISMS document to a polished, auditor-ready PDF: a centred cover + * block, a metadata table, numbered sections, real bordered tables (the + * category issue tables and the key/value overview) and a footer carrying the + * org, classification and page numbers. Mirrors the DOCX renderer's structure. + */ +export function renderIsmsPdf({ + sections, + metadata, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; +}): Buffer { + const pdf = new jsPDF({ unit: 'mm', format: 'a4' }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 18; + const contentWidth = pageWidth - margin * 2; + const accent = accentColor(metadata.primaryColor); + const bottomLimit = pageHeight - 16; + let y = margin; + + const ensureSpace = (needed: number) => { + if (y + needed > bottomLimit) { + pdf.addPage(); + y = margin; + } + }; + + const finalY = (): number => + (pdf as JsPdfWithAutoTable).lastAutoTable?.finalY ?? y; + + const writeWrapped = (text: string, style: 'normal' | 'bold') => { + pdf.setFont('helvetica', style); + pdf.setFontSize(10.5); + pdf.setTextColor(...INK); + for (const line of pdf.splitTextToSize(text, contentWidth)) { + ensureSpace(6); + pdf.text(line, margin, y); + y += 5; + } + }; + + const writeBullet = (text: string) => { + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(10.5); + pdf.setTextColor(...INK); + const lines = pdf.splitTextToSize(text, contentWidth - 6); + lines.forEach((line: string, index: number) => { + ensureSpace(6); + if (index === 0) pdf.text('•', margin + 1, y); + pdf.text(line, margin + 6, y); + y += 5; + }); + y += 1; + }; + + const renderKeyValues = (rows: IsmsKeyValue[]) => { + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + styles: { + fontSize: 9.5, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + columnStyles: { + 0: { cellWidth: 48, fontStyle: 'bold', fillColor: ZEBRA, textColor: MUTED }, + 1: { cellWidth: contentWidth - 48 }, + }, + body: rows.map((row) => [row.label, row.value]), + }); + y = finalY() + 4; + }; + + const renderTable = (table: IsmsExportTable) => { + const threeCol = table.headers.length === 3; + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + head: [table.headers], + body: table.rows, + styles: { + fontSize: 9, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + headStyles: { + fillColor: accent, + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 9, + }, + columnStyles: threeCol + ? { + 0: { cellWidth: 32, fontStyle: 'bold' }, + 1: { cellWidth: 56 }, + 2: { cellWidth: contentWidth - 88 }, + } + : {}, + }); + y = finalY() + 4; + }; + + const renderSection = (section: IsmsExportSection) => { + ensureSpace(14); + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(13); + pdf.setTextColor(...accent); + pdf.text(section.heading, margin, y); + y += 7; + + const hasContent = + Boolean(section.intro) || + Boolean(section.paragraphs?.length) || + Boolean(section.bullets?.length) || + Boolean(section.keyValues?.length) || + Boolean(section.table && section.table.rows.length); + + if (!hasContent) { + writeWrapped(section.emptyText ?? 'No entries recorded.', 'normal'); + y += 4; + return; + } + + if (section.intro) { + writeWrapped(section.intro, 'normal'); + y += 2; + } + for (const paragraph of section.paragraphs ?? []) { + const text = paragraph.label + ? `${paragraph.label}${paragraph.text}` + : paragraph.text; + writeWrapped(text, paragraph.bold ? 'bold' : 'normal'); + y += 1.5; + } + for (const bullet of section.bullets ?? []) writeBullet(bullet); + if (section.keyValues?.length) renderKeyValues(section.keyValues); + if (section.table && section.table.rows.length) renderTable(section.table); + y += 4; + }; + + drawCover(); + renderMetadataTable(); + for (const section of sections) renderSection(section); + drawFooters(); + + return Buffer.from(pdf.output('arraybuffer')); + + function drawCover() { + const centerX = pageWidth / 2; + y = 32; + if (metadata.organizationName) { + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(13); + pdf.setTextColor(...INK); + pdf.text(metadata.organizationName, centerX, y, { align: 'center' }); + y += 8; + } + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(11); + pdf.setTextColor(...MUTED); + pdf.text(metadata.standardLabel, centerX, y, { align: 'center' }); + y += 13; + + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(22); + pdf.setTextColor(...accent); + const titleLines = pdf.splitTextToSize(metadata.title, contentWidth); + pdf.text(titleLines, centerX, y, { align: 'center' }); + y += titleLines.length * 9 + 1; + + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(11); + pdf.setTextColor(...MUTED); + pdf.text(`Clause ${metadata.clause}`, centerX, y, { align: 'center' }); + y += 13; + } + + function renderMetadataTable() { + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + styles: { + fontSize: 9, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + columnStyles: { + 0: { cellWidth: 42, fontStyle: 'bold', fillColor: ZEBRA, textColor: MUTED }, + 1: { cellWidth: contentWidth - 42 }, + }, + body: metadataRows(metadata).map((row) => [row.label, row.value]), + }); + y = finalY() + 10; + } + + function drawFooters() { + const pageCount = pdf.getNumberOfPages(); + const footerY = pageHeight - 9; + const left = [metadata.organizationName, metadata.classification] + .filter(Boolean) + .join(' · '); + for (let page = 1; page <= pageCount; page += 1) { + pdf.setPage(page); + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(8); + pdf.setTextColor(150, 150, 150); + pdf.setDrawColor(...HAIRLINE); + pdf.setLineWidth(0.1); + pdf.line(margin, footerY - 3, pageWidth - margin, footerY - 3); + if (left) pdf.text(left, margin, footerY); + pdf.text( + `${metadata.documentCode} · Page ${page} of ${pageCount}`, + pageWidth - margin, + footerY, + { align: 'right' }, + ); + } + } +} diff --git a/apps/api/src/isms/utils/version-snapshot.spec.ts b/apps/api/src/isms/utils/version-snapshot.spec.ts new file mode 100644 index 0000000000..b9b1484efc --- /dev/null +++ b/apps/api/src/isms/utils/version-snapshot.spec.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@db'; +import { upsertLatestSnapshotVersion } from './version-snapshot'; + +// A fake transaction client: only the ismsDocumentVersion methods the unit +// touches are stubbed. No module mock — the unit under test is imported real. +function makeTx() { + const ismsDocumentVersion = { + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }; + const tx = { ismsDocumentVersion } as unknown as Prisma.TransactionClient; + return { tx, ismsDocumentVersion }; +} + +const snapshot = { frameworkNames: ['ISO 27001'], vendorCount: 3 }; + +describe('upsertLatestSnapshotVersion', () => { + it('updates the existing latest version with the serialized snapshot (UPDATE branch)', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue({ id: 'ver_existing' }); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_1', + snapshot, + }); + + expect(ismsDocumentVersion.findFirst).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', isLatest: true }, + }); + expect(ismsDocumentVersion.update).toHaveBeenCalledWith({ + where: { id: 'ver_existing' }, + data: { sourceSnapshot: snapshot }, + }); + expect(ismsDocumentVersion.create).not.toHaveBeenCalled(); + }); + + it('creates version 1 marked latest when none exists (CREATE branch)', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue(null); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_2', + snapshot, + }); + + expect(ismsDocumentVersion.update).not.toHaveBeenCalled(); + expect(ismsDocumentVersion.create).toHaveBeenCalledWith({ + data: { + documentId: 'doc_2', + version: 1, + isLatest: true, + narrative: {}, + sourceSnapshot: snapshot, + }, + }); + }); + + it('serializes the snapshot through JSON, dropping undefined fields', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue(null); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_3', + snapshot: { keep: 'yes', drop: undefined, nested: { ok: 1 } }, + }); + + const createArg = ismsDocumentVersion.create.mock.calls[0][0]; + expect(createArg.data.sourceSnapshot).toEqual({ + keep: 'yes', + nested: { ok: 1 }, + }); + expect('drop' in createArg.data.sourceSnapshot).toBe(false); + }); +}); diff --git a/apps/api/src/isms/utils/version-snapshot.ts b/apps/api/src/isms/utils/version-snapshot.ts new file mode 100644 index 0000000000..227a79c060 --- /dev/null +++ b/apps/api/src/isms/utils/version-snapshot.ts @@ -0,0 +1,45 @@ +import type { Prisma } from '@db'; + +const EMPTY_NARRATIVE: Prisma.InputJsonValue = {}; + +/** + * Persist the derived-data snapshot onto the document's latest version, creating + * version 1 if none exists. The snapshot is the drift baseline. Serializing + * through JSON keeps it a plain Prisma.InputJsonValue without unsafe casts. The + * existing narrative is preserved (only sourceSnapshot is written). + */ +export async function upsertLatestSnapshotVersion({ + tx, + documentId, + snapshot, +}: { + tx: Prisma.TransactionClient; + documentId: string; + snapshot: unknown; +}): Promise { + const sourceSnapshot: Prisma.InputJsonValue = JSON.parse( + JSON.stringify(snapshot), + ); + + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + + if (latest) { + await tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { sourceSnapshot }, + }); + return; + } + + await tx.ismsDocumentVersion.create({ + data: { + documentId, + version: 1, + isLatest: true, + narrative: EMPTY_NARRATIVE, + sourceSnapshot, + }, + }); +} diff --git a/apps/api/src/isms/wizard/dto/generate-all.dto.ts b/apps/api/src/isms/wizard/dto/generate-all.dto.ts new file mode 100644 index 0000000000..9e8fc98d39 --- /dev/null +++ b/apps/api/src/isms/wizard/dto/generate-all.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class GenerateAllDto { + @ApiProperty({ + description: + 'ID of the framework to generate all ISMS profile documents for', + example: 'frk_abc123def456', + }) + @IsString() + frameworkId!: string; +} diff --git a/apps/api/src/isms/wizard/isms-profile.controller.spec.ts b/apps/api/src/isms/wizard/isms-profile.controller.spec.ts new file mode 100644 index 0000000000..77c7248646 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.controller.spec.ts @@ -0,0 +1,143 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Request } from 'express'; +import { Reflector } from '@nestjs/core'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { + PermissionGuard, + PERMISSIONS_KEY, +} from '../../auth/permission.guard'; +import { IsmsProfileController } from './isms-profile.controller'; +import { IsmsProfileService } from './isms-profile.service'; + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('./isms-profile.service', () => ({ + IsmsProfileService: class MockIsmsProfileService {}, +})); + +const reqWith = (body: unknown): Request => ({ body }) as unknown as Request; + +describe('IsmsProfileController', () => { + let controller: IsmsProfileController; + + const mockService = { + getProfile: jest.fn(), + saveProfile: jest.fn(), + generateAll: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsProfileController], + providers: [{ provide: IsmsProfileService, useValue: mockService }], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsProfileController); + jest.clearAllMocks(); + }); + + describe('getProfile', () => { + it('requires a frameworkId', async () => { + await expect( + controller.getProfile('', 'org_1'), + ).rejects.toThrow(BadRequestException); + }); + + it('delegates to the service with framework + org', async () => { + mockService.getProfile.mockResolvedValue({ answers: null }); + await controller.getProfile('fw_1', 'org_1'); + expect(mockService.getProfile).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + }); + }); + + describe('saveProfile', () => { + it('validates the body and delegates', async () => { + mockService.saveProfile.mockResolvedValue({ id: 'pf_1' }); + await controller.saveProfile( + reqWith({ + frameworkId: 'fw_1', + answers: { hasContractors: true }, + complete: false, + }), + 'org_1', + ); + expect(mockService.saveProfile).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + answers: { hasContractors: true }, + complete: false, + }); + }); + + it('defaults complete to false when omitted', async () => { + mockService.saveProfile.mockResolvedValue({ id: 'pf_1' }); + await controller.saveProfile( + reqWith({ frameworkId: 'fw_1', answers: {} }), + 'org_1', + ); + expect(mockService.saveProfile).toHaveBeenCalledWith( + expect.objectContaining({ complete: false }), + ); + }); + + it('rejects an invalid body with BadRequestException', async () => { + await expect( + controller.saveProfile(reqWith({ answers: {} }), 'org_1'), + ).rejects.toThrow(BadRequestException); + expect(mockService.saveProfile).not.toHaveBeenCalled(); + }); + }); + + describe('generateAll', () => { + it('delegates to the service with framework + org', async () => { + mockService.generateAll.mockResolvedValue({ success: true }); + await controller.generateAll({ frameworkId: 'fw_1' }, 'org_1'); + expect(mockService.generateAll).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + }); + }); + + describe('permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsProfileController) => + reflector.get(PERMISSIONS_KEY, IsmsProfileController.prototype[method]); + + it('gates getProfile with evidence:read', () => { + expect(permissionsFor('getProfile')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates saveProfile and generateAll with evidence:update', () => { + for (const method of ['saveProfile', 'generateAll'] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); + }); +}); diff --git a/apps/api/src/isms/wizard/isms-profile.controller.ts b/apps/api/src/isms/wizard/isms-profile.controller.ts new file mode 100644 index 0000000000..f258bd3373 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.controller.ts @@ -0,0 +1,127 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '@/auth/auth-context.decorator'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { RequirePermission } from '../../auth/require-permission.decorator'; +import { IsmsProfileService } from './isms-profile.service'; +import { GenerateAllDto } from './dto/generate-all.dto'; +import { saveWizardProfileSchema } from './wizard-schema'; + +/** + * OpenAPI body contract for POST /v1/isms/profile. It reads `req.body` directly + * (the global ValidationPipe mangles the nested answers JSON), so the request + * shape is documented explicitly here and validated at runtime by + * `saveWizardProfileSchema`. Mirrors the inline-schema @ApiBody the registers + * controller uses for its @Req()-bodied endpoints (e.g. REGISTER_ROW_BODY). + */ +const SAVE_PROFILE_BODY = { + description: 'Partial ISMS wizard answers (validated at runtime by zod)', + schema: { + type: 'object', + properties: { + frameworkId: { type: 'string' }, + answers: { + type: 'object', + description: + 'Deeply-partial wizard answers (any subset of the wizard steps)', + additionalProperties: true, + }, + complete: { + type: 'boolean', + description: 'Whether the wizard is being finalized', + }, + }, + required: ['frameworkId', 'answers'], + }, +} as const; + +/** + * ISMS wizard profile endpoints (CS-438). The profile holds the ~12 un-derivable + * wizard answers (IsmsProfile.answers) that feed document generation. Shares the + * `isms` controller path and the evidence:read / evidence:update gating used by + * the rest of the ISMS module. + */ +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsProfileController { + constructor(private readonly profileService: IsmsProfileService) {} + + @Get('profile') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Get the ISMS wizard profile, defaults and members' }) + @ApiOkResponse({ description: 'Wizard profile, defaults and member options' }) + async getProfile( + @Query('frameworkId') frameworkId: string, + @OrganizationId() organizationId: string, + ) { + if (!frameworkId) { + throw new BadRequestException('frameworkId is required'); + } + return this.profileService.getProfile({ organizationId, frameworkId }); + } + + @Post('profile') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Save (partial) ISMS wizard answers' }) + @ApiConsumes('application/json') + @ApiBody(SAVE_PROFILE_BODY) + @ApiOkResponse({ description: 'Saved profile' }) + async saveProfile( + // Read req.body directly: ValidationPipe with transform mangles the nested + // answers JSON. We validate with the shared Zod schema instead. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + const parsed = saveWizardProfileSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues); + } + const { frameworkId, answers, complete } = parsed.data; + + return this.profileService.saveProfile({ + organizationId, + frameworkId, + answers, + complete: complete ?? false, + }); + } + + @Post('generate-all') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Ensure and regenerate all ISMS documents' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Regenerated ISMS documents' }) + async generateAll( + @Body() dto: GenerateAllDto, + @OrganizationId() organizationId: string, + ) { + return this.profileService.generateAll({ + organizationId, + frameworkId: dto.frameworkId, + }); + } +} diff --git a/apps/api/src/isms/wizard/isms-profile.service.spec.ts b/apps/api/src/isms/wizard/isms-profile.service.spec.ts new file mode 100644 index 0000000000..b93ba7df88 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.service.spec.ts @@ -0,0 +1,288 @@ +import { NotFoundException } from '@nestjs/common'; +import { ZodError } from 'zod'; +import { db } from '@db'; +import { IsmsProfileService } from './isms-profile.service'; +import { IsmsService } from '../isms.service'; +import { IsmsContextService } from '../isms-context.service'; +import { computeWizardDefaults } from './wizard-defaults'; +import { collectPlatformData } from '../documents/data-source'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + ismsProfile: { + upsert: jest.fn(), + update: jest.fn(), + }, + ismsDocument: { findMany: jest.fn() }, + member: { findMany: jest.fn() }, + }, +})); +jest.mock('./wizard-defaults', () => ({ + computeWizardDefaults: jest.fn(), +})); +jest.mock('../documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockDefaults = jest.mocked(computeWizardDefaults); +const mockCollect = jest.mocked(collectPlatformData); + +const fullAnswers = { + deputySpo: { memberId: 'mem_1', toBeNamed: false }, + internalAuditApproach: 'in_house' as const, + certificationBody: 'BSI', + insurance: { has: true, insurerName: 'Acme Cyber' }, + sectorRegulators: ['FINMA'], + hasContractors: true, + capabilitiesInProduction: ['Payments API'], + cloudScopeSplit: { customer: ['Data'], provider: ['Infra'] }, + euRep: { status: 'appointed' as const, name: 'EU Rep Ltd' }, + certificateScopeSentence: 'The ISMS covers everything.', + objectives: [{ objective: 'Stay certified', target: '100%' }], + intendedOutcomes: ['Protect data'], +}; + +const defaultsFixture = { + capabilitiesInProduction: [], + certificateScopeSentence: 'default sentence', + objectives: [], + intendedOutcomes: [], + cloudScopeSplit: { customer: [], provider: [] }, + sectorRegulatorOptions: [], +}; + +const platformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 0, + subProcessorCount: 0, + vendorsByCategory: {}, + subProcessorNames: [], + infraVendorNames: [], + memberCount: 0, + membersByDepartment: {}, + deviceCount: 0, + riskCount: 0, + highRiskCount: 0, + hasTrainingProgram: false, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +describe('IsmsProfileService', () => { + let service: IsmsProfileService; + let ismsService: jest.Mocked>; + let contextService: jest.Mocked>; + + beforeEach(() => { + jest.clearAllMocks(); + ismsService = { ensureSetup: jest.fn() }; + contextService = { generate: jest.fn() }; + service = new IsmsProfileService( + ismsService as unknown as IsmsService, + contextService as unknown as IsmsContextService, + ); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'fw_1', + }); + mockDefaults.mockResolvedValue(defaultsFixture); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + mockCollect.mockResolvedValue(platformData); + }); + + describe('getProfile', () => { + it('throws NotFoundException when framework not found', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + await expect(service.getProfile(args)).rejects.toThrow(NotFoundException); + }); + + it('upserts the profile row (get-or-init, race-safe)', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + }); + + const result = await service.getProfile(args); + + expect(mockDb.ismsProfile.upsert).toHaveBeenCalledWith({ + where: { + organizationId_frameworkId: { + organizationId: 'org_1', + frameworkId: 'fw_1', + }, + }, + update: {}, + create: { organizationId: 'org_1', frameworkId: 'fw_1', answers: {} }, + }); + expect(result.answers).toBeNull(); + expect(result.defaults).toEqual(defaultsFixture); + expect(result.members).toEqual([]); + }); + + it('returns saved answers when the profile has them', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { hasContractors: true }, + }); + + const result = await service.getProfile(args); + expect(result.answers).toEqual({ hasContractors: true }); + }); + + it('maps members to {id,name} using name then email', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + }); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { id: 'mem_1', user: { name: 'Alice', email: 'a@x.com' } }, + { id: 'mem_2', user: { name: null, email: 'b@x.com' } }, + ]); + + const result = await service.getProfile(args); + expect(result.members).toEqual([ + { id: 'mem_1', name: 'Alice' }, + { id: 'mem_2', name: 'b@x.com' }, + ]); + }); + }); + + describe('saveProfile', () => { + it('merges the partial payload onto stored answers', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { certificationBody: 'BSI' }, + completedAt: null, + }); + (mockDb.ismsProfile.update as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { certificationBody: 'BSI', hasContractors: true }, + completedAt: null, + }); + + await service.saveProfile({ + ...args, + answers: { hasContractors: true }, + complete: false, + }); + + const updateArg = (mockDb.ismsProfile.update as jest.Mock).mock.calls[0][0]; + expect(updateArg.data.answers).toEqual({ + certificationBody: 'BSI', + hasContractors: true, + }); + expect(updateArg.data.completedAt).toBeNull(); + }); + + it('sets completedAt and validates the full schema when complete=true', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + completedAt: null, + }); + (mockDb.ismsProfile.update as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: fullAnswers, + completedAt: new Date(), + }); + + await service.saveProfile({ + ...args, + answers: fullAnswers, + complete: true, + }); + + const updateArg = (mockDb.ismsProfile.update as jest.Mock).mock.calls[0][0]; + expect(updateArg.data.completedAt).toBeInstanceOf(Date); + }); + + it('rejects completion when the merged answers are incomplete', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + completedAt: null, + }); + + await expect( + service.saveProfile({ + ...args, + answers: { certificationBody: 'BSI' }, + complete: true, + }), + ).rejects.toBeInstanceOf(ZodError); + expect(mockDb.ismsProfile.update).not.toHaveBeenCalled(); + }); + }); + + describe('generateAll', () => { + it('ensures setup, collects platform data once, then regenerates every document', async () => { + ismsService.ensureSetup.mockResolvedValue({ + success: true, + documents: [], + }); + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValue([ + { id: 'doc_1', type: 'context_of_organization' }, + { id: 'doc_2', type: 'objectives_plan' }, + ]); + contextService.generate + .mockResolvedValueOnce({ id: 'doc_1' } as never) + .mockResolvedValueOnce({ id: 'doc_2' } as never); + + const result = await service.generateAll(args); + + expect(ismsService.ensureSetup).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + canWrite: true, + }); + // Expensive platform collect runs once for the whole batch. + expect(mockCollect).toHaveBeenCalledTimes(1); + expect(mockCollect).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + expect(contextService.generate).toHaveBeenCalledTimes(2); + // The pre-collected snapshot is threaded into every generate call. + expect(contextService.generate).toHaveBeenNthCalledWith(1, { + documentId: 'doc_1', + organizationId: 'org_1', + data: platformData, + }); + expect(contextService.generate).toHaveBeenNthCalledWith(2, { + documentId: 'doc_2', + organizationId: 'org_1', + data: platformData, + }); + expect(result).toEqual({ + success: true, + documents: [{ id: 'doc_1' }, { id: 'doc_2' }], + }); + }); + + it('generates the parties register before the requirements register', async () => { + ismsService.ensureSetup.mockResolvedValue({ + success: true, + documents: [], + }); + // findMany returns requirements before the register (unordered DB order). + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValue([ + { id: 'doc_reqs', type: 'interested_parties_requirements' }, + { id: 'doc_register', type: 'interested_parties_register' }, + ]); + contextService.generate.mockResolvedValue({ id: 'x' } as never); + + await service.generateAll(args); + + const generatedOrder = contextService.generate.mock.calls.map( + ([call]) => call.documentId, + ); + expect(generatedOrder).toEqual(['doc_register', 'doc_reqs']); + }); + }); +}); diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts new file mode 100644 index 0000000000..e296572061 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -0,0 +1,221 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { IsmsDocumentType, Prisma } from '@db'; +import { IsmsService } from '../isms.service'; +import { IsmsContextService } from '../isms-context.service'; +import { collectPlatformData } from '../documents/data-source'; +import { computeWizardDefaults } from './wizard-defaults'; +import { mergeWizardAnswers } from './merge-answers'; +import { + parseStoredAnswers, + wizardAnswersSchema, + type PartialWizardAnswers, +} from './wizard-schema'; + +/** A member option surfaced for the Deputy SPO / sign-off pickers. */ +export interface WizardMemberOption { + id: string; + name: string; +} + +/** + * Deterministic generation order for generateAll. The Parties register MUST be + * generated before the Requirements register, since requirements derive from + * the persisted parties; an unordered findMany could regenerate requirements + * against a stale/empty register. Lower number = generated first; unlisted + * types fall back to GENERATION_ORDER_DEFAULT. + */ +const GENERATION_ORDER: Record = { + context_of_organization: 0, + interested_parties_register: 1, + interested_parties_requirements: 2, + isms_scope: 3, + leadership_commitment: 4, + objectives_plan: 5, +}; +const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; + +/** + * IsmsProfile lifecycle: get-or-init, partial save, completion, and the + * wizard-driven generate-all. One profile row per org + framework. The answers + * JSON is validated against the shared wizard Zod schema on read and write. + */ +@Injectable() +export class IsmsProfileService { + constructor( + private readonly ismsService: IsmsService, + private readonly contextService: IsmsContextService, + ) {} + + /** + * Return the saved answers (or null), the computed pre-population defaults, and + * the member options. Ensures the profile row exists (get-or-init), mirroring + * ensure-setup, so the wizard always has a row to PATCH against. + */ + async getProfile({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }): Promise<{ + answers: PartialWizardAnswers | null; + defaults: Awaited>; + members: WizardMemberOption[]; + }> { + await this.requireFramework({ frameworkId }); + + const profile = await this.ensureProfile({ organizationId, frameworkId }); + const [defaults, members] = await Promise.all([ + computeWizardDefaults({ organizationId, frameworkId }), + this.listMembers({ organizationId }), + ]); + + const stored = parseStoredAnswers(profile.answers); + const hasAnswers = Object.keys(stored).length > 0; + + return { + answers: hasAnswers ? stored : null, + defaults, + members, + }; + } + + /** + * Merge a partial answers payload onto the stored answers and save. When + * `complete` is true the merged result is validated against the full schema and + * completedAt is set. + */ + async saveProfile({ + organizationId, + frameworkId, + answers, + complete, + }: { + organizationId: string; + frameworkId: string; + answers: PartialWizardAnswers; + complete: boolean; + }) { + await this.requireFramework({ frameworkId }); + + const profile = await this.ensureProfile({ organizationId, frameworkId }); + const stored = parseStoredAnswers(profile.answers); + const merged = mergeWizardAnswers({ stored, incoming: answers }); + + if (complete) { + wizardAnswersSchema.parse(merged); + } + + const serialized: Prisma.InputJsonValue = JSON.parse(JSON.stringify(merged)); + + const updated = await db.ismsProfile.update({ + where: { id: profile.id }, + data: { + answers: serialized, + completedAt: complete ? new Date() : profile.completedAt, + }, + }); + + return { + id: updated.id, + answers: parseStoredAnswers(updated.answers), + completedAt: updated.completedAt, + }; + } + + /** + * Ensure all six ISMS documents exist, then regenerate each from the latest + * profile + platform data. Called by the wizard on completion so every document + * reflects the answers just saved. Returns the regenerated documents. + */ + async generateAll({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }) { + await this.requireFramework({ frameworkId }); + + // The wizard is an evidence:update flow, so it always provisions. + await this.ismsService.ensureSetup({ + organizationId, + frameworkId, + canWrite: true, + }); + + const documents = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + select: { id: true, type: true }, + }); + const ordered = [...documents].sort( + (a, b) => + (GENERATION_ORDER[a.type] ?? GENERATION_ORDER_DEFAULT) - + (GENERATION_ORDER[b.type] ?? GENERATION_ORDER_DEFAULT), + ); + + // Collect the expensive platform snapshot once and reuse it across every + // document, instead of re-querying it per document inside generate(). + const data = await collectPlatformData({ organizationId, frameworkId }); + + type GeneratedDocument = Awaited< + ReturnType + >; + const generated: GeneratedDocument[] = []; + for (const doc of ordered) { + const result = await this.contextService.generate({ + documentId: doc.id, + organizationId, + data, + }); + generated.push(result); + } + + return { success: true, documents: generated }; + } + + private async ensureProfile({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }) { + const empty: Prisma.InputJsonValue = {}; + // Idempotent: concurrent callers can't trip the unique constraint. + return db.ismsProfile.upsert({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + update: {}, + create: { organizationId, frameworkId, answers: empty }, + }); + } + + private async listMembers({ + organizationId, + }: { + organizationId: string; + }): Promise { + const members = await db.member.findMany({ + where: { organizationId, deactivated: false }, + select: { id: true, user: { select: { name: true, email: true } } }, + orderBy: { createdAt: 'asc' }, + }); + + return members.map((member) => ({ + id: member.id, + name: member.user?.name || member.user?.email || 'Unknown member', + })); + } + + private async requireFramework({ frameworkId }: { frameworkId: string }) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); + if (!framework) { + throw new NotFoundException('Framework not found'); + } + return framework; + } +} diff --git a/apps/api/src/isms/wizard/merge-answers.spec.ts b/apps/api/src/isms/wizard/merge-answers.spec.ts new file mode 100644 index 0000000000..c73c9e212b --- /dev/null +++ b/apps/api/src/isms/wizard/merge-answers.spec.ts @@ -0,0 +1,48 @@ +import { mergeWizardAnswers } from './merge-answers'; + +describe('mergeWizardAnswers', () => { + it('shallow-merges nested objects without clobbering siblings', () => { + const merged = mergeWizardAnswers({ + stored: { insurance: { has: true, insurerName: 'Acme' } }, + incoming: { insurance: { insurerName: 'Beta' } }, + }); + expect(merged.insurance).toEqual({ has: true, insurerName: 'Beta' }); + }); + + it('replaces scalars and arrays wholesale', () => { + const merged = mergeWizardAnswers({ + stored: { + certificationBody: 'BSI', + sectorRegulators: ['FINMA'], + }, + incoming: { sectorRegulators: ['FCA', 'HIPAA'] }, + }); + expect(merged.certificationBody).toBe('BSI'); + expect(merged.sectorRegulators).toEqual(['FCA', 'HIPAA']); + }); + + it('ignores undefined incoming fields', () => { + const merged = mergeWizardAnswers({ + stored: { hasContractors: true }, + incoming: { hasContractors: undefined }, + }); + expect(merged.hasContractors).toBe(true); + }); + + it('does not mutate the stored input', () => { + const stored = { insurance: { has: false, insurerName: '' } }; + mergeWizardAnswers({ + stored, + incoming: { insurance: { has: true } }, + }); + expect(stored.insurance.has).toBe(false); + }); + + it('seeds a nested object that did not exist before', () => { + const merged = mergeWizardAnswers({ + stored: {}, + incoming: { deputySpo: { toBeNamed: true } }, + }); + expect(merged.deputySpo).toEqual({ toBeNamed: true }); + }); +}); diff --git a/apps/api/src/isms/wizard/merge-answers.ts b/apps/api/src/isms/wizard/merge-answers.ts new file mode 100644 index 0000000000..9380c17d43 --- /dev/null +++ b/apps/api/src/isms/wizard/merge-answers.ts @@ -0,0 +1,41 @@ +import type { PartialWizardAnswers } from './wizard-schema'; + +/** + * Merge an incoming partial wizard payload onto the stored answers. Nested + * objects (deputySpo, insurance, cloudScopeSplit, euRep) are shallow-merged so a + * single-field PATCH does not clobber sibling fields; scalars and arrays are + * replaced wholesale. Returns a new object — neither input is mutated. + */ +export function mergeWizardAnswers({ + stored, + incoming, +}: { + stored: PartialWizardAnswers; + incoming: PartialWizardAnswers; +}): PartialWizardAnswers { + const merged: PartialWizardAnswers = { ...stored }; + + for (const key of Object.keys(incoming) as Array) { + const value = incoming[key]; + if (value === undefined) continue; + + if (isPlainObject(value)) { + const current = merged[key]; + const base: Record = isPlainObject(current) + ? current + : {}; + Object.assign(merged, { [key]: { ...base, ...value } }); + continue; + } + + Object.assign(merged, { [key]: value }); + } + + return merged; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) + ); +} diff --git a/apps/api/src/isms/wizard/wizard-defaults.spec.ts b/apps/api/src/isms/wizard/wizard-defaults.spec.ts new file mode 100644 index 0000000000..5f01627702 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-defaults.spec.ts @@ -0,0 +1,110 @@ +import { db } from '@db'; +import { collectPlatformData } from '../documents/data-source'; +import { + DEFAULT_CLOUD_SCOPE_SPLIT, + DEFAULT_INTENDED_OUTCOMES, + computeWizardDefaults, +} from './wizard-defaults'; +import { SECTOR_REGULATOR_OPTIONS } from './wizard-schema'; +import type { IsmsPlatformData } from '../documents/types'; + +jest.mock('@db', () => ({ + db: { context: { findFirst: jest.fn() } }, +})); +jest.mock('../documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); + +const platformData: IsmsPlatformData = { + organizationName: 'Acme Inc', + frameworkNames: ['ISO 27001'], + vendorCount: 2, + subProcessorCount: 1, + vendorsByCategory: { cloud: 2 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +describe('computeWizardDefaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCollect.mockResolvedValue(platformData); + }); + + it('returns the certificate scope sentence from the scope derivation', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.certificateScopeSentence).toContain('Acme Inc'); + expect(result.certificateScopeSentence).toContain('ISO 27001'); + }); + + it('returns the default objectives (objective + target) from the derivation', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.objectives.length).toBeGreaterThan(0); + expect(result.objectives[0]).toHaveProperty('objective'); + expect(result.objectives[0]).toHaveProperty('target'); + }); + + it('returns the static intended outcomes, cloud split and regulator options', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + expect(result.cloudScopeSplit).toEqual(DEFAULT_CLOUD_SCOPE_SPLIT); + expect(result.sectorRegulatorOptions).toEqual([...SECTOR_REGULATOR_OPTIONS]); + }); + + it('splits the Types of Services context answer into capabilities', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: '- Payments API\n- Reporting dashboard\n- Mobile app', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'Payments API', + 'Reporting dashboard', + 'Mobile app', + ]); + }); + + it('returns [] capabilities when no services context exists', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([]); + }); + + it('splits a prose paragraph answer into per-sentence capabilities', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: + 'The company provides a payments API. It also offers a reporting dashboard. Its model includes a mobile app.', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'The company provides a payments API.', + 'It also offers a reporting dashboard.', + 'Its model includes a mobile app.', + ]); + }); + + it('does not shred decimals or single-clause prose into fragments', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: 'A single hosted platform with 99.9% uptime for enterprise teams.', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'A single hosted platform with 99.9% uptime for enterprise teams.', + ]); + }); +}); diff --git a/apps/api/src/isms/wizard/wizard-defaults.ts b/apps/api/src/isms/wizard/wizard-defaults.ts new file mode 100644 index 0000000000..8044156d0d --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-defaults.ts @@ -0,0 +1,113 @@ +import { db } from '@db'; +import { collectPlatformData } from '../documents/data-source'; +import { deriveScopeNarrative } from '../documents/scope'; +import { deriveObjectives } from '../documents/objectives'; +import { SECTOR_REGULATOR_OPTIONS } from './wizard-schema'; + +/** The Context Q&A question key that stores the org's services description. */ +const SERVICES_CONTEXT_QUESTION = 'Types of Services Provided'; + +/** Default intended ISMS outcomes (5.x) offered for confirmation. */ +export const DEFAULT_INTENDED_OUTCOMES: string[] = [ + 'Protect the confidentiality, integrity and availability of information assets.', + 'Meet applicable legal, regulatory and contractual information-security obligations.', + 'Maintain customer and stakeholder trust through demonstrable security practices.', + 'Identify, assess and treat information-security risks within defined tolerances.', + 'Continually improve the effectiveness of the information security management system.', +]; + +/** Default cloud scope split between customer- and provider-managed layers (4.3). */ +export const DEFAULT_CLOUD_SCOPE_SPLIT: { + customer: string[]; + provider: string[]; +} = { + customer: ['Data', 'Databases', 'Application configuration'], + provider: ['Underlying infrastructure'], +}; + +/** The shape returned by GET /v1/isms/profile under `defaults`. */ +export interface WizardDefaults { + capabilitiesInProduction: string[]; + certificateScopeSentence: string; + objectives: Array<{ objective: string; target: string }>; + intendedOutcomes: string[]; + cloudScopeSplit: { customer: string[]; provider: string[] }; + sectorRegulatorOptions: string[]; +} + +/** + * Compute the pre-population defaults for the wizard. Sourced from the same + * platform-data + derivation logic that drives document generation, so the + * confirmed-default flow stays consistent with what generation would produce. + */ +export async function computeWizardDefaults({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [data, capabilitiesInProduction] = await Promise.all([ + collectPlatformData({ organizationId, frameworkId }), + loadServicesFromContext({ organizationId }), + ]); + + const scope = deriveScopeNarrative(data); + const objectives = deriveObjectives(data).map((row) => ({ + objective: row.objective, + target: row.target ?? '', + })); + + return { + capabilitiesInProduction, + certificateScopeSentence: scope.certificateScopeSentence, + objectives, + intendedOutcomes: DEFAULT_INTENDED_OUTCOMES, + cloudScopeSplit: DEFAULT_CLOUD_SCOPE_SPLIT, + sectorRegulatorOptions: [...SECTOR_REGULATOR_OPTIONS], + }; +} + +/** + * Read the org's "Types of Services Provided" Context answer and split it into a + * list of candidate capabilities. The answer is free text, so we split on lines / + * bullets / common separators and drop empties. Returns [] when absent. + */ +async function loadServicesFromContext({ + organizationId, +}: { + organizationId: string; +}): Promise { + const entry = await db.context.findFirst({ + where: { organizationId, question: SERVICES_CONTEXT_QUESTION }, + select: { answer: true }, + }); + if (!entry?.answer) return []; + + return splitServicesAnswer(entry.answer); +} + +/** + * Split the services answer into individual capability items. + * + * The answer is usually one of two shapes: an explicitly delimited list (lines, + * bullets, semicolons) or a short prose paragraph (the AI-generated default is a + * ~60-word paragraph of declarative sentences). When explicit delimiters are + * present we honour them; otherwise we fall back to sentence boundaries so prose + * still renders as a real per-item tick-list rather than one lump (CS-437). + * + * Sentence splitting only fires on a period/!/? followed by whitespace and a + * capital letter, so decimals (`99.9%`) and lowercase abbreviations don't get + * shredded into garbage fragments. If even that yields a single item (a genuine + * one-clause blob), it stays as one item. + */ +function splitServicesAnswer(answer: string): string[] { + const hasExplicitDelimiters = /\r?\n|[•·;]/.test(answer); + const parts = hasExplicitDelimiters + ? answer.split(/\r?\n|[•·;]/) + : answer.split(/(?<=[.!?])\s+(?=[A-Z])/); + + return parts + .map((item) => item.replace(/^[\s\-*\d.)]+/, '').trim()) + .filter((item) => item.length > 0); +} diff --git a/apps/api/src/isms/wizard/wizard-schema.spec.ts b/apps/api/src/isms/wizard/wizard-schema.spec.ts new file mode 100644 index 0000000000..61fcc979b9 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-schema.spec.ts @@ -0,0 +1,100 @@ +import { + parseStoredAnswers, + partialWizardAnswersSchema, + saveWizardProfileSchema, + wizardAnswersSchema, +} from './wizard-schema'; + +const fullAnswers = { + deputySpo: { memberId: 'mem_1', toBeNamed: false }, + internalAuditApproach: 'in_house' as const, + certificationBody: 'BSI', + insurance: { has: true, insurerName: 'Acme Cyber' }, + sectorRegulators: ['FINMA', 'custom:Local Authority'], + hasContractors: true, + capabilitiesInProduction: ['Payments API'], + cloudScopeSplit: { customer: ['Data'], provider: ['Infrastructure'] }, + euRep: { status: 'appointed' as const, name: 'EU Rep Ltd' }, + certificateScopeSentence: 'The ISMS covers everything.', + objectives: [{ objective: 'Stay certified', target: '100%' }], + intendedOutcomes: ['Protect data'], +}; + +describe('wizardAnswersSchema (full)', () => { + it('accepts a fully-populated answers object', () => { + expect(wizardAnswersSchema.safeParse(fullAnswers).success).toBe(true); + }); + + it('rejects a missing required field on completion', () => { + const { certificationBody, ...rest } = fullAnswers; + expect(wizardAnswersSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects an invalid enum value', () => { + expect( + wizardAnswersSchema.safeParse({ + ...fullAnswers, + internalAuditApproach: 'guesswork', + }).success, + ).toBe(false); + }); + + it('allows null internalAuditApproach', () => { + expect( + wizardAnswersSchema.safeParse({ + ...fullAnswers, + internalAuditApproach: null, + }).success, + ).toBe(true); + }); +}); + +describe('partialWizardAnswersSchema (incremental save)', () => { + it('accepts an empty object', () => { + expect(partialWizardAnswersSchema.safeParse({}).success).toBe(true); + }); + + it('accepts a single-step payload', () => { + const parsed = partialWizardAnswersSchema.safeParse({ + insurance: { has: true, insurerName: 'X' }, + }); + expect(parsed.success).toBe(true); + }); + + it('still validates types within a partial payload', () => { + expect( + partialWizardAnswersSchema.safeParse({ hasContractors: 'yes' }).success, + ).toBe(false); + }); +}); + +describe('saveWizardProfileSchema', () => { + it('requires a frameworkId', () => { + expect( + saveWizardProfileSchema.safeParse({ answers: {} }).success, + ).toBe(false); + }); + + it('accepts frameworkId + partial answers + complete flag', () => { + const parsed = saveWizardProfileSchema.safeParse({ + frameworkId: 'fw_1', + answers: { certificationBody: 'BSI' }, + complete: true, + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('parseStoredAnswers', () => { + it('returns {} for malformed input', () => { + expect(parseStoredAnswers('not-json')).toEqual({}); + expect(parseStoredAnswers(null)).toEqual({}); + expect(parseStoredAnswers(undefined)).toEqual({}); + }); + + it('returns the parsed partial answers for valid input', () => { + expect(parseStoredAnswers({ hasContractors: true })).toEqual({ + hasContractors: true, + }); + }); +}); diff --git a/apps/api/src/isms/wizard/wizard-schema.ts b/apps/api/src/isms/wizard/wizard-schema.ts new file mode 100644 index 0000000000..c62b4f8740 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-schema.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; + +/** + * The single source of truth for the IsmsProfile.answers JSON blob (CS-438). + * These are the ~12 wizard answers that cannot be derived from platform data and + * that feed ISMS document generation. Validated on every read and write. + * + * - `wizardAnswersSchema` validates the full, completed shape (used on complete). + * - `partialWizardAnswersSchema` validates a partial save while the user steps + * through the wizard (every field optional, deeply). + */ + +export const INTERNAL_AUDIT_APPROACHES = [ + 'in_house', + 'external_firm', + 'training_planned', +] as const; + +export const EU_REP_STATUSES = ['appointed', 'not_required', 'pending'] as const; + +/** + * Suggested sector-regulator options surfaced by the wizard. Customers may also + * send a free-text value prefixed with `custom:` (e.g. `custom:My Regulator`). + */ +export const SECTOR_REGULATOR_OPTIONS = [ + 'FINMA', + 'FCA', + 'HIPAA', + 'PCI DSS', + 'healthcare', + 'critical_infrastructure', +] as const; + +const deputySpoSchema = z.object({ + memberId: z.string().nullable(), + toBeNamed: z.boolean(), +}); + +const insuranceSchema = z.object({ + has: z.boolean(), + insurerName: z.string(), +}); + +const cloudScopeSplitSchema = z.object({ + customer: z.array(z.string()), + provider: z.array(z.string()), +}); + +const euRepSchema = z.object({ + status: z.enum(EU_REP_STATUSES), + name: z.string(), +}); + +const objectiveSchema = z.object({ + objective: z.string(), + target: z.string(), +}); + +/** The full, completed WizardAnswers shape (validated on complete=true). */ +export const wizardAnswersSchema = z.object({ + deputySpo: deputySpoSchema, + internalAuditApproach: z.enum(INTERNAL_AUDIT_APPROACHES).nullable(), + certificationBody: z.string(), + insurance: insuranceSchema, + sectorRegulators: z.array(z.string()), + hasContractors: z.boolean(), + capabilitiesInProduction: z.array(z.string()), + cloudScopeSplit: cloudScopeSplitSchema, + euRep: euRepSchema, + certificateScopeSentence: z.string(), + objectives: z.array(objectiveSchema), + intendedOutcomes: z.array(z.string()), +}); + +export type WizardAnswers = z.infer; + +/** + * Deeply-partial variant for incremental saves. Nested objects/arrays are all + * optional so the client can PATCH a single step's answers. + */ +export const partialWizardAnswersSchema = z.object({ + deputySpo: deputySpoSchema.partial().optional(), + internalAuditApproach: z.enum(INTERNAL_AUDIT_APPROACHES).nullable().optional(), + certificationBody: z.string().optional(), + insurance: insuranceSchema.partial().optional(), + sectorRegulators: z.array(z.string()).optional(), + hasContractors: z.boolean().optional(), + capabilitiesInProduction: z.array(z.string()).optional(), + cloudScopeSplit: cloudScopeSplitSchema.partial().optional(), + euRep: euRepSchema.partial().optional(), + certificateScopeSentence: z.string().optional(), + objectives: z.array(objectiveSchema).optional(), + intendedOutcomes: z.array(z.string()).optional(), +}); + +export type PartialWizardAnswers = z.infer; + +/** The body schema for POST /v1/isms/profile. */ +export const saveWizardProfileSchema = z.object({ + frameworkId: z.string().min(1), + answers: partialWizardAnswersSchema, + complete: z.boolean().optional(), +}); + +export type SaveWizardProfileInput = z.infer; + +/** + * Parse a stored answers blob (Prisma JSON) into a partial WizardAnswers. Unknown + * shapes degrade to an empty object so a malformed row never breaks reads. + */ +export function parseStoredAnswers(value: unknown): PartialWizardAnswers { + const parsed = partialWizardAnswersSchema.safeParse(value); + return parsed.success ? parsed.data : {}; +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index f9f324e13d..e4d3676ac2 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -18,24 +18,12 @@ import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; import { evidenceFormDefinitionList, meetingSubTypeValues } from '../forms'; +import { useIso27001FrameworkId } from '../isms/hooks/useIso27001FrameworkId'; import { SOAOverviewCard } from './SOAOverviewCard'; type FormStatuses = Record; -type FrameworkListResponse = { - data: Array<{ - id: string; - frameworkId: string; - framework: { - id: string; - name: string; - description: string | null; - visible: boolean; - }; - }>; -}; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; -const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; const MEETING_SUB_TYPES = meetingSubTypeValues; const MEETING_ALL_TYPES = new Set([...MEETING_SUB_TYPES, 'meeting']); @@ -119,6 +107,7 @@ function StatusBadge({ } export function CompanyOverviewCards({ organizationId }: { organizationId: string }) { + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); const swrKey: readonly [string, string] = ['/v1/evidence-forms/statuses', organizationId]; const { data: statuses } = useSWR( @@ -133,16 +122,6 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin ); const { data: findingsResponse } = useOrganizationFindings(); - const { data: frameworksResponse } = useSWR( - ['/v1/frameworks', organizationId] as const, - async ([endpoint, orgId]: readonly [string, string]) => { - const response = await apiClient.get(endpoint, orgId); - if (response.error || !response.data) { - throw new Error(response.error ?? 'Failed to load frameworks'); - } - return response.data; - }, - ); const activeIssueCounts = useMemo(() => { const counts: Record = {}; @@ -178,16 +157,6 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin return map; }, [visibleForms]); - const iso27001Framework = useMemo(() => { - const frameworks = frameworksResponse?.data ?? []; - return frameworks.find( - (frameworkInstance) => - !!frameworkInstance.framework?.name && - ISO27001_NAMES.includes(frameworkInstance.framework.name), - ); - }, [frameworksResponse]); - const iso27001FrameworkId = iso27001Framework?.frameworkId ?? null; - return ( {iso27001FrameworkId && ( diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx index 4602d20e99..2f8e337038 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useFeatureFlag } from '@trycompai/analytics'; import { PageHeader, PageLayout, @@ -11,24 +12,57 @@ import { import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; import { useCallback } from 'react'; +import { useIso27001FrameworkId } from '../isms/hooks/useIso27001FrameworkId'; + +/** + * PostHog flag gating the ISMS area while it's privately tested. Enable it + * per-org in PostHog to expose the tab. Local development falls through so the + * tab stays visible without PostHog configured. + */ +const ISMS_FEATURE_FLAG = 'is-isms-enabled'; interface DocumentsPageTabsProps { - overviewContent: ReactNode; + organizationId: string; + ismsContent: ReactNode; + companyFormsContent: ReactNode; settingsContent: ReactNode; } -const DEFAULT_TAB = 'overview'; +const ISMS_TAB = 'iso-27001'; +const COMPANY_FORMS_TAB = 'overview'; +const SETTINGS_TAB = 'settings'; +const DEFAULT_TAB = COMPANY_FORMS_TAB; -function tabParamToInternal(tabParam: string | null): string { - if (tabParam === 'settings') return 'settings'; +function tabParamToInternal({ + tabParam, + showIsmsTab, +}: { + tabParam: string | null; + showIsmsTab: boolean; +}): string { + if (tabParam === SETTINGS_TAB) return SETTINGS_TAB; + if (tabParam === ISMS_TAB && showIsmsTab) return ISMS_TAB; return DEFAULT_TAB; } -export function DocumentsPageTabs({ overviewContent, settingsContent }: DocumentsPageTabsProps) { +export function DocumentsPageTabs({ + organizationId, + ismsContent, + companyFormsContent, + settingsContent, +}: DocumentsPageTabsProps) { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); - const activeTab = tabParamToInternal(searchParams.get('tab')); + // The ISO 27001 (ISMS) tab appears only when the organization has ISO 27001 + // active AND the ISMS feature flag is enabled (private testing). Development + // falls through so the tab is visible locally without PostHog. + const hasIso27001 = !!useIso27001FrameworkId(organizationId); + const ismsFlagEnabled = useFeatureFlag(ISMS_FEATURE_FLAG); + const showIsmsTab = + hasIso27001 && + (ismsFlagEnabled || process.env.NODE_ENV === 'development'); + const activeTab = tabParamToInternal({ tabParam: searchParams.get('tab'), showIsmsTab }); const handleTabChange = useCallback( (value: string) => { @@ -44,6 +78,9 @@ export function DocumentsPageTabs({ overviewContent, settingsContent }: Document [pathname, router, searchParams], ); + // When ISO 27001 is not active the IA is unchanged: a single "Overview" tab plus Settings. + const companyFormsLabel = showIsmsTab ? 'Company Forms' : 'Overview'; + return ( - Overview - Settings + {showIsmsTab && ISO 27001 (ISMS)} + {companyFormsLabel} + Settings } /> } > - {overviewContent} - {settingsContent} + {showIsmsTab && {ismsContent}} + {companyFormsContent} + {settingsContent} ); diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx index 8c45498b31..760ebffc61 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -1,23 +1,15 @@ -import { - Badge, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Text, -} from '@trycompai/design-system'; -import { api } from '@/lib/api-client'; -import { usePermissions } from '@/hooks/use-permissions'; -import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; +import { api } from '@/lib/api-client'; +import { usePermissions } from '@/hooks/use-permissions'; +import { IsmsDocumentCard } from '../isms/components/shared'; +import type { IsmsDocumentStatus } from '../isms/isms-types'; const STATEMENT_OF_APPLICABILITY_FORM = { type: 'statement-of-applicability', title: 'Statement of Applicability', description: - "Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your organization's policies and documentation.", + "Auto-completed for ISO 27001 from your organization's policies and documentation.", } as const; interface SOAOverviewCardProps { @@ -36,71 +28,24 @@ type SOASetupResponse = { } | null; }; -type SOAApprovalStatus = - | 'Approved' - | 'Declined' - | 'Pending' - | 'Not approved' - | 'Loading' - | 'Unavailable'; - -function SOAApprovalStatusBadge({ status }: { status: SOAApprovalStatus }) { - const statusConfig: Record< - SOAApprovalStatus, - { label: SOAApprovalStatus; className: string } - > = { - Loading: { - label: 'Loading', - className: - 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', - }, - Unavailable: { - label: 'Unavailable', - className: - 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', - }, - Approved: { - label: 'Approved', - className: - 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', - }, - Pending: { - label: 'Pending', - className: - 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', - }, - Declined: { - label: 'Declined', - className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', - }, - 'Not approved': { - label: 'Not approved', - className: - 'bg-slate-100 text-slate-800 dark:bg-slate-950/30 dark:text-slate-400', - }, - }; - - const { label, className } = statusConfig[status]; - return ( - - {label} - - ); +/** Map the SOA document state onto the shared ISMS status vocabulary. */ +function toIsmsStatus(document: SOASetupResponse['document']): IsmsDocumentStatus | null { + if (!document) return null; + if (document.approvedAt) return 'approved'; + if (document.declinedAt) return 'declined'; + if (document.status === 'needs_review' || document.approverId) return 'needs_review'; + return 'draft'; } -export function SOAOverviewCard({ - organizationId, - iso27001FrameworkId, -}: SOAOverviewCardProps) { +export function SOAOverviewCard({ organizationId, iso27001FrameworkId }: SOAOverviewCardProps) { const form = STATEMENT_OF_APPLICABILITY_FORM; const { hasPermission } = usePermissions(); + // Least privilege: only audit:create users hit the writing ensure-setup; + // read-only users use the read-only get-setup endpoint. const soaEndpoint = hasPermission('audit', 'create') ? '/v1/soa/ensure-setup' : '/v1/soa/get-setup'; - const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } = - useSWR( + const { data: soaSetupResponse, error: soaSetupError } = useSWR( [soaEndpoint, organizationId, iso27001FrameworkId], async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { const response = await api.post(endpoint, { @@ -117,45 +62,20 @@ export function SOAOverviewCard({ }, ); - const document = soaSetupResponse?.document; - const approvalStatus = useMemo(() => { - if (isLoadingSOASetup) return 'Loading'; - if (soaSetupError || !soaSetupResponse?.success) return 'Unavailable'; - if (!document) return 'Not approved'; - if (document.approvedAt) return 'Approved'; - if (document.declinedAt) return 'Declined'; - if ( - document.status === 'needs_review' || - !!document.approverId - ) { - return 'Pending'; - } - return 'Not approved'; - }, [document, isLoadingSOASetup, soaSetupError, soaSetupResponse?.success]); + const status = useMemo(() => { + if (soaSetupError || !soaSetupResponse?.success) return null; + return toIsmsStatus(soaSetupResponse.document); + }, [soaSetupError, soaSetupResponse]); return ( -
-
- - {form.title} - - 1 -
-
- - - - {form.title} -
- {form.description} -
-
- - - -
- -
+
+
); } diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx new file mode 100644 index 0000000000..a1a226bec0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mock api client ───────────────────────────────────────── +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('@/lib/api-client', () => ({ + api: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})); + +// ─── Mock SWR (synchronous, key-aware) ─────────────────────── +type SWRKey = readonly unknown[] | string | null; +vi.mock('swr', () => ({ + default: (key: SWRKey) => { + if (Array.isArray(key) && key[0] === '/v1/frameworks') { + return { + data: { + data: [{ id: 'fi-1', frameworkId: 'fw-iso', framework: { id: 'fw-iso', name: 'ISO 27001' } }], + }, + }; + } + if (Array.isArray(key) && key[0] === '/v1/isms/ensure-setup') { + return { + data: { + success: true, + documents: [ + { id: 'd1', type: 'context_of_organization', status: 'draft', requirementId: null, hasApprovedVersion: false }, + ], + }, + }; + } + if (Array.isArray(key) && key[2] === 'drift') { + return { data: { isStale: false, changedSources: [] } }; + } + // SOAOverviewCard's own ensure-setup + return { data: { success: true, configuration: {}, document: null }, isLoading: false, error: null }; + }, +})); + +// ─── Mock design system ────────────────────────────────────── +type Kids = { children?: React.ReactNode }; +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: Kids) =>
{children}
, + AlertTitle: ({ children }: Kids) => {children}, + AlertDescription: ({ children }: Kids) =>
{children}
, + Spinner: () => , + Badge: ({ children }: Kids) => {children}, + Button: ({ children }: Kids) => , + Card: ({ children }: Kids) =>
{children}
, + CardContent: ({ children }: Kids) =>
{children}
, + CardDescription: ({ children }: Kids) =>

{children}

, + CardHeader: ({ children }: Kids) =>
{children}
, + CardTitle: ({ children }: Kids) =>

{children}

, + Grid: ({ children }: Kids) =>
{children}
, + Heading: ({ children }: Kids) =>

{children}

, + HStack: ({ children }: Kids) =>
{children}
, + Stack: ({ children }: Kids) =>
{children}
, + Section: ({ title, description, actions, children }: Kids & { + title?: string; + description?: string; + actions?: React.ReactNode; + }) => ( +
+ {title ?

{title}

: null} + {description ?

{description}

: null} + {actions} + {children} +
+ ), + Text: ({ children }: Kids) => {children}, + Empty: ({ children }: Kids) =>
{children}
, + EmptyContent: ({ children }: Kids) =>
{children}
, + EmptyDescription: ({ children }: Kids) =>

{children}

, + EmptyHeader: ({ children }: Kids) =>
{children}
, + EmptyMedia: ({ children }: Kids) =>
{children}
, + EmptyTitle: ({ children }: Kids) =>

{children}

, +})); + +// ─── Mock design-system icons ──────────────────────────────── +vi.mock('@trycompai/design-system/icons', () => { + const Icon = () => ; + return { + ArrowLeft: Icon, + ArrowRight: Icon, + CheckmarkFilled: Icon, + CircleDash: Icon, + DocumentMultiple_01: Icon, + Edit: Icon, + Incomplete: Icon, + MagicWand: Icon, + Misuse: Icon, + Renew: Icon, + Time: Icon, + WarningAlt: Icon, + WarningAltFilled: Icon, + }; +}); + +// ─── Mock next/link ────────────────────────────────────────── +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +import { IsmsOverview } from './IsmsOverview'; + +describe('IsmsOverview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all 6 foundational document cards', () => { + render(); + + expect(screen.getByText(/Context of the Organization/)).toBeInTheDocument(); + expect(screen.getByText(/Interested Parties Register/)).toBeInTheDocument(); + expect(screen.getByText(/Interested Parties Requirements/)).toBeInTheDocument(); + expect(screen.getByText(/ISMS Scope/)).toBeInTheDocument(); + expect(screen.getByText(/Leadership and Commitment/)).toBeInTheDocument(); + expect(screen.getByText(/Information Security Objectives and Plan/)).toBeInTheDocument(); + }); + + it('renders the Foundational Documents section heading', () => { + render(); + expect(screen.getByText('Foundational Documents')).toBeInTheDocument(); + }); + + it('does not render the Statement of Applicability (it lives in general documents)', () => { + render(); + // SOA was moved out of the ISMS tab back into the general documents list. + expect(screen.queryByText('Statement of Applicability')).not.toBeInTheDocument(); + }); + + it('links the Context of the Organization card to its detail page', () => { + render(); + const contextLink = screen + .getAllByRole('link') + .find((link) => link.getAttribute('href')?.includes('/documents/isms/context-of-organization')); + expect(contextLink).toBeDefined(); + }); + + it('links all six foundational documents to their detail pages', () => { + render(); + // All six foundational documents are now implemented — none are "Coming soon". + expect(screen.queryByText('Coming soon')).not.toBeInTheDocument(); + const ismsDetailLinks = screen + .getAllByRole('link') + .filter((link) => link.getAttribute('href')?.includes('/documents/isms/')); + expect(ismsDetailLinks).toHaveLength(6); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx new file mode 100644 index 0000000000..18b0c6ec91 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Grid, + Section, + Spinner, + Stack, +} from '@trycompai/design-system'; +import { + CheckmarkFilled, + DocumentMultiple_01, + Incomplete, + MagicWand, + Renew, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { usePermissions } from '@/hooks/use-permissions'; +import { api } from '@/lib/api-client'; +import { useIso27001FrameworkId } from '../../isms/hooks/useIso27001FrameworkId'; +import { + ISMS_TYPE_META, + ismsTypeToSlug, + type IsmsDriftResult, + type IsmsEnsureSetupResponse, +} from '../../isms/isms-types'; +import { + IsmsDocumentCard, + IsmsEmptyState, + IsmsSummaryRow, + type IsmsSummaryStat, +} from '../../isms/components/shared'; + +export function IsmsOverview({ organizationId }: { organizationId: string }) { + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); + const { hasPermission } = usePermissions(); + const canRunWizard = hasPermission('evidence', 'update'); + + const { + data: setupResponse, + error: setupError, + isLoading: isSetupLoading, + mutate: mutateSetup, + } = useSWR( + iso27001FrameworkId + ? (['/v1/isms/ensure-setup', organizationId, iso27001FrameworkId] as const) + : null, + async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { + const response = await api.post(endpoint, { + frameworkId, + }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load ISMS documents'); + } + return response.data; + }, + ); + + const documents = useMemo(() => { + const list = setupResponse?.documents; + return Array.isArray(list) ? list : []; + }, [setupResponse]); + + const contextDoc = documents.find((doc) => doc.type === 'context_of_organization'); + + const { data: contextDrift } = useSWR( + contextDoc ? (['/v1/isms/documents', contextDoc.id, 'drift'] as const) : null, + async ([base, id]: readonly [string, string, string]) => { + const response = await api.get(`${base}/${id}/drift`); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load drift status'); + } + return response.data; + }, + ); + + const isContextStale = !!contextDrift?.isStale; + + const summary = useMemo(() => { + const total = ISMS_TYPE_META.length; + const approved = documents.filter((doc) => doc.status === 'approved').length; + const outstanding = total - approved; + const needsReview = isContextStale ? 1 : 0; + return [ + { label: 'Documents', value: total, icon: DocumentMultiple_01 }, + { label: 'Approved', value: approved, icon: CheckmarkFilled, tone: 'success' }, + { label: 'Outstanding', value: outstanding, icon: Incomplete }, + { + label: 'Needs review', + value: needsReview, + icon: WarningAltFilled, + tone: needsReview > 0 ? 'warning' : 'default', + }, + ]; + }, [documents, isContextStale]); + + if (!iso27001FrameworkId) { + return ( + + ); + } + + // Surface a load failure with a retry instead of a silently zeroed summary. + if (setupError && !setupResponse) { + return ( + }> + Couldn't load your ISMS documents + +
+
+ {setupError instanceof Error + ? setupError.message + : 'Something went wrong loading your ISMS foundational documents.'} +
+
+ +
+
+
+
+ ); + } + + // Loading the first response: show a spinner rather than an all-zero summary. + if (!setupResponse && isSetupLoading) { + return ( +
+ +
+ ); + } + + const wizardAction = canRunWizard ? ( + + + + ) : undefined; + + return ( + + + +
+ + {ISMS_TYPE_META.map((meta) => { + const setupDoc = documents.find((doc) => doc.type === meta.type); + const isStale = meta.type === 'context_of_organization' ? isContextStale : false; + return ( + + ); + })} + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx new file mode 100644 index 0000000000..cfa51758d0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -0,0 +1,168 @@ +import { Breadcrumb, PageLayout, Text } from '@trycompai/design-system'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { serverApi } from '@/lib/api-server'; +import { hasPermission } from '@/lib/permissions'; +import { resolveUserPermissions } from '@/lib/permissions.server'; +import { auth } from '@/utils/auth'; +import { ContextOfOrganizationClient } from '../components/ContextOfOrganizationClient'; +import { InterestedPartiesClient } from '../components/InterestedPartiesClient'; +import type { ApproverOption } from '../components/IsmsApprovalSection'; +import { LeadershipClient } from '../components/LeadershipClient'; +import { ObjectivesClient } from '../components/ObjectivesClient'; +import { RequirementsClient } from '../components/RequirementsClient'; +import { ScopeClient } from '../components/ScopeClient'; +import { + ISMS_SLUG_TO_TYPE, + ISMS_TYPE_META, + ISO27001_NAMES, + type IsmsDocument as IsmsDocumentData, + type IsmsDocumentType, + type IsmsEnsureSetupResponse, +} from '../isms-types'; + +/** Shared props every ISMS detail client receives. */ +interface IsmsDetailClientProps { + organizationId: string; + documentId: string; + fallbackData: IsmsDocumentData | null; + currentMemberId: string | null; + approverOptions: ApproverOption[]; +} + +const ISMS_DETAIL_CLIENTS: Record< + IsmsDocumentType, + (props: IsmsDetailClientProps) => React.JSX.Element +> = { + context_of_organization: ContextOfOrganizationClient, + interested_parties_register: InterestedPartiesClient, + interested_parties_requirements: RequirementsClient, + objectives_plan: ObjectivesClient, + isms_scope: ScopeClient, + leadership_commitment: LeadershipClient, +}; + +interface FrameworkApiResponse { + data: Array<{ id: string; frameworkId: string; framework: { id: string; name: string } }>; +} + +interface PeopleApiResponse { + data: Array<{ + id: string; + role: string; + userId: string; + deactivated: boolean; + user: { id: string; name: string | null; email: string }; + }>; +} + +export default async function IsmsDocumentPage({ + params, +}: { + params: Promise<{ orgId: string; type: string }>; +}) { + const { orgId, type: typeSlug } = await params; + const documentType = ISMS_SLUG_TO_TYPE[typeSlug]; + if (!documentType) notFound(); + + const meta = ISMS_TYPE_META.find((entry) => entry.type === documentType); + if (!meta) notFound(); + + const breadcrumb = ( + }, + }, + { label: meta.title, isCurrent: true }, + ]} + /> + ); + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) notFound(); + const organizationId = session.session.activeOrganizationId ?? orgId; + + const [frameworksResult, peopleResult] = await Promise.all([ + serverApi.get('/v1/frameworks'), + serverApi.get('/v1/people'), + ]); + + const frameworks = frameworksResult.data?.data ?? []; + const isoFramework = frameworks.find( + (instance) => instance.framework?.name && ISO27001_NAMES.includes(instance.framework.name), + ); + + if (!isoFramework) { + return ( + + {breadcrumb} +
+ + Add the ISO 27001 framework to your organization to manage this document. + +
+
+ ); + } + + const setupResult = await serverApi.post('/v1/isms/ensure-setup', { + frameworkId: isoFramework.frameworkId, + }); + + const setupDoc = setupResult.data?.documents?.find((doc) => doc.type === documentType); + if (!setupDoc) { + return ( + + {breadcrumb} +
+ Unable to load this document. Please try again later. +
+
+ ); + } + + const documentResult = await serverApi.get( + `/v1/isms/documents/${setupDoc.id}`, + ); + const fallbackData = documentResult.data ?? null; + + // If /v1/people is unavailable to this user (e.g. no member:read), approval + // simply degrades to "unavailable" — no approvers, no current member — rather + // than breaking the page. + const people = peopleResult.data?.data ?? []; + const currentMember = people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; + + // An approver is any active member whose effective permissions include + // evidence:update (the same gate that lets a user manage ISMS documents), + // resolved via RBAC rather than hardcoded role strings. + const activeMembers = people.filter((p) => !p.deactivated); + const approverFlags = await Promise.all( + activeMembers.map(async (p) => { + const permissions = await resolveUserPermissions(p.role, organizationId); + return hasPermission(permissions, 'evidence', 'update'); + }), + ); + const approverOptions: ApproverOption[] = activeMembers + .filter((_, index) => approverFlags[index]) + .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const DetailClient = ISMS_DETAIL_CLIENTS[documentType]; + + return ( + + {breadcrumb} + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx new file mode 100644 index 0000000000..fbfc89ad48 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Field, + FieldError, + HStack, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import { Controller, useForm } from 'react-hook-form'; +import { categoriesForKind, type IsmsContextIssueKind } from '../isms-types'; +import { issueSchema, type IssueFormValues } from './issue-schema'; +import { IsmsAddCard, IsmsFieldLabel } from './shared'; + +interface AddIssueFormProps { + kind: IsmsContextIssueKind; + onAdd: (params: IssueFormValues) => Promise; +} + +export function AddIssueForm({ kind, onAdd }: AddIssueFormProps) { + return ( + + {({ close }) => } + + ); +} + +function AddIssueFields({ + kind, + onAdd, + onClose, +}: AddIssueFormProps & { onClose: () => void }) { + const categories = categoriesForKind(kind); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(issueSchema), + defaultValues: { category: categories[0], description: '', effect: '' }, + }); + + const handleAdd = handleSubmit(async (values) => { + try { + await onAdd(values); + } catch { + // Keep the user's input and the form open when the save fails. + return; + } + reset({ category: categories[0], description: '', effect: '' }); + onClose(); + }); + + return ( +
+ + ( + + )} + /> + {errors.category?.message} + +
+ + ( +