diff --git a/packages/iios-service/prisma/migrations/20260702213408_dsr_erasure/migration.sql b/packages/iios-service/prisma/migrations/20260702213408_dsr_erasure/migration.sql new file mode 100644 index 0000000..e14dade --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260702213408_dsr_erasure/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "IiosSourceHandle" ADD COLUMN "redactedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "IiosComplianceHold" ( + "id" TEXT NOT NULL, + "scopeId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "holdReason" TEXT NOT NULL, + "appliedByActorId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "releasedAt" TIMESTAMP(3), + + CONSTRAINT "IiosComplianceHold_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IiosComplianceHold_scopeId_targetType_targetId_status_idx" ON "IiosComplianceHold"("scopeId", "targetType", "targetId", "status"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 3bb9f81..6cb4413 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -326,6 +326,7 @@ model IiosSourceHandle { firstSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now()) metadata Json? + redactedAt DateTime? // set when this subject has been erased (DSR, P9) actors IiosActorRef[] @@ -492,6 +493,23 @@ model IiosProcessedEvent { @@id([consumerName, eventId]) } +/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS +/// erasure/retention deletion until an authorised release. Never auto-released. +model IiosComplianceHold { + id String @id @default(cuid()) + scopeId String + targetType String // 'data_subject' | 'message' | 'media' + targetId String + holdReason String + appliedByActorId String? + status String @default("ACTIVE") // ACTIVE | RELEASED + createdAt DateTime @default(now()) + expiresAt DateTime? + releasedAt DateTime? + + @@index([scopeId, targetType, targetId, status]) +} + /// Projection replay cursor (P9, KG-06). Ordered high-water-mark + rolling checksum per /// (projection, topic, partition) proving replay reproduces the same projection outcome. model IiosProjectionCursor { diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 5210d1a..64d9707 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -15,6 +15,7 @@ import { RoutingModule } from './routing/routing.module'; import { AiModule } from './ai/ai.module'; import { CalendarModule } from './calendar/calendar.module'; import { CapabilityModule } from './capability/capability.module'; +import { DsrModule } from './dsr/dsr.module'; import { HealthController } from './health.controller'; import { MetricsController } from './observability/metrics.controller'; import { DevController } from './dev/dev.controller'; @@ -37,6 +38,7 @@ import { DevController } from './dev/dev.controller'; AiModule, CalendarModule, CapabilityModule, + DsrModule, ], controllers: [HealthController, MetricsController, DevController], }) diff --git a/packages/iios-service/src/dsr/dsr.controller.ts b/packages/iios-service/src/dsr/dsr.controller.ts new file mode 100644 index 0000000..78b7411 --- /dev/null +++ b/packages/iios-service/src/dsr/dsr.controller.ts @@ -0,0 +1,44 @@ +import { BadRequestException, Body, Controller, Get, Headers, Param, Post } from '@nestjs/common'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { DsrService } from './dsr.service'; +import { PlaceHoldDto } from './dsr.dto'; + +/** + * Data-subject rights (P9 DSR). Tenant-scoped: every op acts within the caller's own + * scope. Erasure is self-service (the caller erases their own subject); holds are + * managed by the tenant's admins. + */ +@Controller('v1/dsr') +export class DsrController { + constructor( + private readonly dsr: DsrService, + private readonly session: SessionVerifier, + ) {} + + @Post('erase') + async erase(@Headers('authorization') auth?: string) { + return this.dsr.eraseSubject(this.principal(auth)); + } + + @Post('holds') + async placeHold(@Body() body: PlaceHoldDto, @Headers('authorization') auth?: string) { + return this.dsr.placeHold(this.principal(auth), { targetType: body.targetType, targetId: body.targetId, reason: body.reason, expiresAt: body.expiresAt }); + } + + @Get('holds') + async listHolds(@Headers('authorization') auth?: string) { + return this.dsr.listHolds(this.principal(auth)); + } + + @Post('holds/:id/release') + async releaseHold(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.dsr.releaseHold(this.principal(auth), id); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/dsr/dsr.dto.ts b/packages/iios-service/src/dsr/dsr.dto.ts new file mode 100644 index 0000000..892572d --- /dev/null +++ b/packages/iios-service/src/dsr/dsr.dto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class PlaceHoldDto { + @IsIn(['data_subject', 'message', 'media']) targetType!: 'data_subject' | 'message' | 'media'; + @IsString() targetId!: string; // an id, or 'self' for the caller's own subject + @IsString() reason!: string; + @IsOptional() @IsString() expiresAt?: string; +} diff --git a/packages/iios-service/src/dsr/dsr.module.ts b/packages/iios-service/src/dsr/dsr.module.ts new file mode 100644 index 0000000..897f688 --- /dev/null +++ b/packages/iios-service/src/dsr/dsr.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DsrService } from './dsr.service'; +import { DsrController } from './dsr.controller'; + +/** Data-subject rights (P9 DSR): right-to-erasure + compliance holds. */ +@Module({ + controllers: [DsrController], + providers: [DsrService], + exports: [DsrService], +}) +export class DsrModule {} diff --git a/packages/iios-service/src/dsr/dsr.service.spec.ts b/packages/iios-service/src/dsr/dsr.service.spec.ts new file mode 100644 index 0000000..372d7ba --- /dev/null +++ b/packages/iios-service/src/dsr/dsr.service.spec.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { ConflictException } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { SupportService } from '../support/support.service'; +import { MessageService } from '../messaging/message.service'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { DsrService } from './dsr.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const actors = new ActorResolver(asService); + +const dsr = () => new DsrService(asService, actors); +const support = () => new SupportService(asService, makeFakePorts(), actors, new IdempotencyService(asService)); +const messages = () => new MessageService(asService, makeFakePorts(), actors); + +const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Jane Doe' }; + +/** Seed the caller as a data subject with PII: a ticket subject + an authored message body. */ +async function seedSubject(p: MessagePrincipal, subject: string, body: string) { + const ticket = await support().createTicket(p, { subject }); + const m = messages(); + const { threadId } = await m.openThread(null, p); + const msg = await m.send(threadId, p, { content: body }, `k-${randomUUID()}`); + return { ticketId: ticket.id, interactionId: msg.id }; +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('DsrService — right-to-erasure (P9 slice 8)', () => { + it('redacts PII in place, preserves externalId, and stamps redactedAt', async () => { + const { ticketId, interactionId } = await seedSubject(cust, 'my SSN is 123-45', 'call me at 555-0100'); + const before = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'cust' } }); + + const res = await dsr().eraseSubject(cust); + expect(res.status).toBe('ERASED'); + + const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } }); + expect(part.bodyText).toBe('[redacted]'); + const ticket = await prisma.iiosTicket.findUniqueOrThrow({ where: { id: ticketId } }); + expect(ticket.subject).toBe('[redacted]'); + const handle = await prisma.iiosSourceHandle.findUniqueOrThrow({ where: { id: before.id } }); + expect(handle.displayName).toBe('[redacted]'); + expect(handle.externalId).toBe('cust'); // source preservation — external id kept + expect(handle.redactedAt).toBeTruthy(); + }); + + it('preserves the audit trail + structure (ledgers and rows survive)', async () => { + const { ticketId, interactionId } = await seedSubject(cust, 'secret', 'secret body'); + await dsr().eraseSubject(cust); + + // audit link recorded, and the ticket-created ledger event still exists + expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.subject.erased' } })).toBe(1); + expect(await prisma.iiosOutboxEvent.count()).toBeGreaterThan(0); + // structure kept: the interaction + ticket rows still exist (redacted, not deleted) + expect(await prisma.iiosInteraction.findUnique({ where: { id: interactionId } })).not.toBeNull(); + expect(await prisma.iiosTicket.findUnique({ where: { id: ticketId } })).not.toBeNull(); + }); + + it('is idempotent — a second erase is a no-op', async () => { + await seedSubject(cust, 's', 'b'); + expect((await dsr().eraseSubject(cust)).status).toBe('ERASED'); + expect((await dsr().eraseSubject(cust)).status).toBe('ALREADY_ERASED'); + }); + + it('an active compliance hold blocks erasure until released', async () => { + const { interactionId } = await seedSubject(cust, 's', 'private body'); + await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'litigation' }); + + await expect(dsr().eraseSubject(cust)).rejects.toBeInstanceOf(ConflictException); + const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } }); + expect(part.bodyText).toBe('private body'); // NOT redacted while held + expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.erasure.blocked' } })).toBe(1); + + const hold = (await dsr().listHolds(cust))[0]!; + await dsr().releaseHold(cust, hold.id); + expect((await dsr().eraseSubject(cust)).status).toBe('ERASED'); + }); + + it('holds are tenant-fenced (a second tenant cannot see or release them)', async () => { + await seedSubject(cust, 's', 'b'); + const hold = await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'legal' }); + + const other: MessagePrincipal = { userId: 'intruder', orgId: 'org_other', appId: 'portal-demo', displayName: 'B' }; + expect(await dsr().listHolds(other)).toHaveLength(0); + await expect(dsr().releaseHold(other, hold.id)).rejects.toThrow(); + }); +}); diff --git a/packages/iios-service/src/dsr/dsr.service.ts b/packages/iios-service/src/dsr/dsr.service.ts new file mode 100644 index 0000000..bbc00e1 --- /dev/null +++ b/packages/iios-service/src/dsr/dsr.service.ts @@ -0,0 +1,121 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { recordAudit } from '../observability/audit'; + +const REDACTED = '[redacted]'; + +export interface PlaceHoldInput { + targetType: string; + targetId: string; + reason: string; + expiresAt?: string; +} + +/** + * Data-subject rights (P9 DSR, KG-03). Right-to-erasure is doc-mandated as + * tombstone/redact — never a hard delete: PII columns are stripped in place while + * structure, IDs, and the audit trail are preserved ("source preservation"). An active + * compliance hold blocks erasure until an authorised release. All ops are audited and + * strictly tenant-scoped (the caller can only act within their own scope). + */ +@Injectable() +export class DsrService { + constructor( + private readonly prisma: PrismaService, + private readonly actors: ActorResolver, + ) {} + + /** Erase the CALLER's own subject (self-service GDPR "erase me"). */ + async eraseSubject(principal: MessagePrincipal) { + const scope = await this.actors.findScope(principal); + if (!scope) return { status: 'NOTHING_TO_ERASE' as const }; + + const handle = await this.prisma.iiosSourceHandle.findUnique({ + where: { scopeId_kind_externalId: { scopeId: scope.id, kind: 'PORTAL_USER', externalId: principal.userId } }, + include: { actors: true }, + }); + if (!handle) return { status: 'NOTHING_TO_ERASE' as const }; + if (handle.redactedAt) return { status: 'ALREADY_ERASED' as const, subjectId: handle.id }; + + // A live compliance hold over this subject blocks erasure (legal hold overrides erasure). + const hold = await this.prisma.iiosComplianceHold.findFirst({ + where: { + scopeId: scope.id, + targetType: 'data_subject', + targetId: handle.id, + status: 'ACTIVE', + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + }); + if (hold) { + await recordAudit(this.prisma, { action: 'dsr.erasure.blocked', resourceType: 'data_subject', resourceId: handle.id, scopeId: scope.id }); + throw new ConflictException('erasure blocked by an active compliance hold'); + } + + const actorIds = handle.actors.map((a) => a.id); + const interactionIds = ( + await this.prisma.iiosInteraction.findMany({ where: { actorId: { in: actorIds } }, select: { id: true } }) + ).map((i) => i.id); + + await this.prisma.$transaction([ + // Message content + raw provider payloads authored by the subject. + this.prisma.iiosMessagePart.updateMany({ where: { interactionId: { in: interactionIds } }, data: { bodyText: REDACTED, contentRef: null } }), + this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: { in: interactionIds } }, data: { payload: { redacted: true } } }), + // Support + inbox PII the subject authored/owns. + this.prisma.iiosTicket.updateMany({ where: { requesterActorId: { in: actorIds } }, data: { subject: REDACTED } }), + this.prisma.iiosCallbackRequest.updateMany({ where: { requesterActorId: { in: actorIds } }, data: { notes: null } }), + this.prisma.iiosInboxItem.updateMany({ where: { ownerActorId: { in: actorIds } }, data: { title: REDACTED, summary: null } }), + // Meeting transcript content the subject spoke; revoke their recording consent. + this.prisma.iiosTranscriptSegment.updateMany({ where: { speakerActorId: { in: actorIds } }, data: { contentRef: REDACTED } }), + this.prisma.iiosMeetingParticipant.updateMany({ where: { actorRefId: { in: actorIds } }, data: { recordingConsent: 'REVOKED' } }), + // Identity: redact member-facing display, PRESERVE externalId (source preservation). + this.prisma.iiosActorRef.updateMany({ where: { id: { in: actorIds } }, data: { displayName: REDACTED } }), + this.prisma.iiosSourceHandle.update({ where: { id: handle.id }, data: { displayName: REDACTED, metadata: Prisma.DbNull, redactedAt: new Date() } }), + ]); + + await recordAudit(this.prisma, { action: 'dsr.subject.erased', resourceType: 'data_subject', resourceId: handle.id, scopeId: scope.id }); + return { status: 'ERASED' as const, subjectId: handle.id, redactedInteractions: interactionIds.length }; + } + + async placeHold(principal: MessagePrincipal, input: PlaceHoldInput) { + const scope = await this.actors.resolveScope(principal); + let targetId = input.targetId; + if (targetId === 'self') { + const handle = await this.prisma.iiosSourceHandle.findUnique({ + where: { scopeId_kind_externalId: { scopeId: scope.id, kind: 'PORTAL_USER', externalId: principal.userId } }, + }); + if (!handle) throw new NotFoundException('no subject exists for the caller yet'); + targetId = handle.id; + } + const appliedBy = await this.actors.resolveActor(scope.id, principal); + const hold = await this.prisma.iiosComplianceHold.create({ + data: { + scopeId: scope.id, + targetType: input.targetType, + targetId, + holdReason: input.reason, + appliedByActorId: appliedBy.id, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, + }, + }); + await recordAudit(this.prisma, { action: 'dsr.hold.placed', resourceType: 'compliance_hold', resourceId: hold.id, scopeId: scope.id, actorRefId: appliedBy.id }); + return hold; + } + + async releaseHold(principal: MessagePrincipal, id: string) { + const hold = await this.prisma.iiosComplianceHold.findUnique({ where: { id } }); + if (!hold) throw new NotFoundException('compliance hold not found'); + const scope = await this.actors.assertOwns(principal, hold.scopeId); // KG-02 fence + const released = await this.prisma.iiosComplianceHold.update({ where: { id }, data: { status: 'RELEASED', releasedAt: new Date() } }); + await recordAudit(this.prisma, { action: 'dsr.hold.released', resourceType: 'compliance_hold', resourceId: id, scopeId: scope.id }); + return released; + } + + async listHolds(principal: MessagePrincipal) { + const scope = await this.actors.findScope(principal); + if (!scope) return []; + return this.prisma.iiosComplianceHold.findMany({ where: { scopeId: scope.id, status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } }); + } +} diff --git a/packages/iios-service/src/test-utils/reset-db.ts b/packages/iios-service/src/test-utils/reset-db.ts index 7dabe6b..6b2f86c 100644 --- a/packages/iios-service/src/test-utils/reset-db.ts +++ b/packages/iios-service/src/test-utils/reset-db.ts @@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client'; export async function resetDb(prisma: PrismaClient): Promise { await prisma.$executeRawUnsafe( `TRUNCATE TABLE - "IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink", + "IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",