Feat/s3 storage #2

Closed
maaz519 wants to merge 30 commits from feat/s3-storage into main
2 changed files with 109 additions and 8 deletions
Showing only changes of commit 65023ce404 - Show all commits
@@ -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);
} }