Files
iios/packages/iios-service/src/retention/retention.service.ts
T
maaz519 65023ce404 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>
2026-07-18 12:56:22 +05:30

173 lines
7.5 KiB
TypeScript

import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { recordAudit } from '../observability/audit';
const DAY_MS = 86_400_000;
export interface SweepResult {
archived: number;
redacted: number;
skippedHeld: number;
}
/**
* Data retention (P9). Each interaction gets a per-resource retention snapshot with
* frozen archive/delete timestamps; a scheduled sweep archives (status change) then
* deletes = redacts-in-place (reusing the DSR tombstone semantics) once a resource ages
* past its window. An active compliance hold blocks both. Never a hard delete.
*/
@Injectable()
export class RetentionService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RetentionService.name);
private timer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {}
onModuleInit(): void {
const ms = Number(process.env.IIOS_RETENTION_SWEEP_INTERVAL_MS ?? 0);
if (ms > 0) {
this.timer = setInterval(() => {
void this.sweep().catch((err) => this.logger.warn(`retention sweep failed: ${(err as Error).message}`));
}, ms);
}
}
onModuleDestroy(): void {
if (this.timer) clearInterval(this.timer);
}
private windowDays(dataClass: string, kind: 'ARCHIVE' | 'DELETE'): number {
const cls = process.env[`IIOS_RETENTION_${kind}_DAYS_${dataClass.toUpperCase()}`];
const glob = process.env[`IIOS_RETENTION_${kind}_DAYS`];
return Number(cls ?? glob ?? (kind === 'ARCHIVE' ? 90 : 365));
}
/** Lazily capture a per-resource snapshot for any interaction that lacks one. */
async ensureSnapshots(scopeId?: string): Promise<number> {
const interactions = await this.prisma.iiosInteraction.findMany({
where: scopeId ? { scopeId } : {},
select: { id: true, scopeId: true, dataClass: true, receivedAt: true },
});
let created = 0;
for (const i of interactions) {
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
where: { targetType_targetId: { targetType: 'interaction', targetId: i.id } },
});
if (exists) continue;
const base = i.receivedAt.getTime();
await this.prisma.iiosRetentionPolicySnapshot.create({
data: {
policyKey: `default:${i.dataClass}:v1`,
scopeSnapshotId: i.scopeId,
targetType: 'interaction',
targetId: i.id,
dataClass: i.dataClass,
archiveAfter: new Date(base + this.windowDays(i.dataClass, 'ARCHIVE') * DAY_MS),
deleteAfter: new Date(base + this.windowDays(i.dataClass, 'DELETE') * DAY_MS),
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
},
});
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. */
async applySweep(scopeId?: string): Promise<SweepResult> {
const now = new Date();
const due = await this.prisma.iiosRetentionPolicySnapshot.findMany({
where: { status: { in: ['ACTIVE', 'ARCHIVED'] }, archiveAfter: { lte: now }, ...(scopeId ? { scopeSnapshotId: scopeId } : {}) },
});
const result: SweepResult = { archived: 0, redacted: 0, skippedHeld: 0 };
for (const s of due) {
const held = await this.prisma.iiosComplianceHold.findFirst({
where: { targetId: s.targetId, status: 'ACTIVE', OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
});
if (held) {
result.skippedHeld++;
continue;
}
if (s.deleteAfter <= now) {
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' } });
await recordAudit(this.prisma, { action: 'retention.archived', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
result.archived++;
}
}
return result;
}
/** 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);
}
/** Snapshot lifecycle counts for /metrics (global ops). */
async summary(): Promise<{ total: number; byStatus: Record<string, number> }> {
const groups = await this.prisma.iiosRetentionPolicySnapshot.groupBy({ by: ['status'], _count: true });
const byStatus: Record<string, number> = {};
let total = 0;
for (const g of groups) {
byStatus[g.status] = g._count;
total += g._count;
}
return { total, byStatus };
}
}