feat(iios): DSR right-to-erasure (redact-in-place) + compliance hold (P9)
Adds IiosComplianceHold + IiosSourceHandle.redactedAt and a DsrService whose eraseSubject redacts the caller's PII in place (message bodies, identity display, raw payloads, support/inbox/transcript text) while preserving externalId (source preservation), structure, and the audit trail. An active compliance hold blocks erasure until released; every op is audited and tenant-fenced. Erasure is idempotent (KG-03). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
|
||||
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user