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:
@@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
||||
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. */
|
||||
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||
const now = new Date();
|
||||
@@ -90,13 +125,22 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
if (s.deleteAfter <= now) {
|
||||
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 });
|
||||
if (s.targetType === 'outbound_command') {
|
||||
// Redact the recipient address + rendered body; KEEP the template provenance columns.
|
||||
await this.prisma.$transaction([
|
||||
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' } }),
|
||||
]);
|
||||
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++;
|
||||
} else if (s.status === 'ACTIVE') {
|
||||
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||
@@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
||||
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> {
|
||||
await this.ensureSnapshots(scopeId);
|
||||
await this.ensureOutboundSnapshots(scopeId);
|
||||
return this.applySweep(scopeId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user