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,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");
@@ -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 {
+2
View File
@@ -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);
}
}
+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' } });
}
}
@@ -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",