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:
2026-07-03 03:24:43 +05:30
parent 67da42a103
commit 611ca74cea
9 changed files with 322 additions and 1 deletions
@@ -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);
}
}
+8
View File
@@ -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' } });
}
}