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
|
||||
});
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user