feat(templates): T8 PII redaction of outbound commands (fast-follow)
An email/SMS command holds PII: the recipient address (target) and the rendered
body (payload). RetentionService now snapshots outbound commands and, once aged,
redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING
the template provenance (key/version/locale/hash) — so 'which template version did
we send?' stays answerable after the PII is gone.
- ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound',
archiveAfter==deleteAfter (PII goes straight to redact, no archive phase).
Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it.
- applySweep redact branch switches on targetType; compliance holds honored; audit
'retention.redacted' resourceType 'outbound_command'.
Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => {
|
|||||||
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with
|
||||||
|
// their name). Once aged, redact those while KEEPING the template provenance for audit/replay.
|
||||||
|
describe('RetentionService — outbound command PII (T8)', () => {
|
||||||
|
const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
|
||||||
|
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data });
|
||||||
|
|
||||||
|
async function command(target: string): Promise<{ id: string; scopeId: string }> {
|
||||||
|
const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
|
||||||
|
const cmd = await prisma.iiosOutboundCommand.create({
|
||||||
|
data: {
|
||||||
|
channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`,
|
||||||
|
payload: { subject: 'Hi Dana', html: '<p>secret body</p>' },
|
||||||
|
templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: cmd.id, scopeId: scope.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => {
|
||||||
|
const { id } = await command('dana@acme.com');
|
||||||
|
const n = await svc().ensureOutboundSnapshots();
|
||||||
|
expect(n).toBe(1);
|
||||||
|
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } });
|
||||||
|
expect(snap.targetType).toBe('outbound_command');
|
||||||
|
expect(snap.dataClass).toBe('outbound');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts target + payload of an aged command but keeps template provenance', async () => {
|
||||||
|
const { id } = await command('dana@acme.com');
|
||||||
|
await svc().ensureOutboundSnapshots();
|
||||||
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
||||||
|
|
||||||
|
const res = await svc().applySweep();
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(after.target).toBe('[redacted]');
|
||||||
|
expect(after.payload).toEqual({ redacted: true });
|
||||||
|
// Provenance survives — you can still answer "which template version did we send?"
|
||||||
|
expect(after.templateKey).toBe('welcome');
|
||||||
|
expect(after.templateVersion).toBe(1);
|
||||||
|
expect(after.renderedHash).toBe('abc123');
|
||||||
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an active compliance hold blocks the command sweep', async () => {
|
||||||
|
const { id, scopeId } = await command('held@acme.com');
|
||||||
|
await svc().ensureOutboundSnapshots();
|
||||||
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
||||||
|
await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } });
|
||||||
|
|
||||||
|
const res = await svc().applySweep();
|
||||||
|
expect(res.skippedHeld).toBe(1);
|
||||||
|
expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a retention snapshot for any outbound command that lacks one. An email/SMS command
|
||||||
|
* holds PII (the recipient address, and the rendered body with their name), so it gets a single
|
||||||
|
* window and goes STRAIGHT to redact (archiveAfter == deleteAfter — no archive phase). Unscoped
|
||||||
|
* system sends use an 'unscoped' partition tag so the global sweep still reaches them.
|
||||||
|
*/
|
||||||
|
async ensureOutboundSnapshots(scopeId?: string): Promise<number> {
|
||||||
|
const commands = await this.prisma.iiosOutboundCommand.findMany({
|
||||||
|
where: scopeId ? { scopeId } : {},
|
||||||
|
select: { id: true, scopeId: true, createdAt: true },
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const c of commands) {
|
||||||
|
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
|
||||||
|
where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS);
|
||||||
|
await this.prisma.iiosRetentionPolicySnapshot.create({
|
||||||
|
data: {
|
||||||
|
policyKey: 'outbound:pii:v1',
|
||||||
|
scopeSnapshotId: c.scopeId ?? 'unscoped',
|
||||||
|
targetType: 'outbound_command',
|
||||||
|
targetId: c.id,
|
||||||
|
dataClass: 'outbound',
|
||||||
|
archiveAfter: deleteAt,
|
||||||
|
deleteAfter: deleteAt,
|
||||||
|
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
||||||
async applySweep(scopeId?: string): Promise<SweepResult> {
|
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -90,13 +125,22 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (s.deleteAfter <= now) {
|
if (s.deleteAfter <= now) {
|
||||||
await this.prisma.$transaction([
|
if (s.targetType === 'outbound_command') {
|
||||||
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
// Redact the recipient address + rendered body; KEEP the template provenance columns.
|
||||||
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
await this.prisma.$transaction([
|
||||||
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }),
|
||||||
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||||
]);
|
]);
|
||||||
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
} else {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
||||||
|
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
||||||
|
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
||||||
|
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||||
|
]);
|
||||||
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
}
|
||||||
result.redacted++;
|
result.redacted++;
|
||||||
} else if (s.status === 'ACTIVE') {
|
} else if (s.status === 'ACTIVE') {
|
||||||
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||||
@@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full sweep: capture missing snapshots, then act on due ones. */
|
/** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */
|
||||||
async sweep(scopeId?: string): Promise<SweepResult> {
|
async sweep(scopeId?: string): Promise<SweepResult> {
|
||||||
await this.ensureSnapshots(scopeId);
|
await this.ensureSnapshots(scopeId);
|
||||||
|
await this.ensureOutboundSnapshots(scopeId);
|
||||||
return this.applySweep(scopeId);
|
return this.applySweep(scopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user