Skip to content

Commit d20bf5c

Browse files
authored
Merge pull request #1091 from OutstandingVick/agent/issue-1047-soft-delete-audit
Backend: introduce soft-delete and audit trails for critical entities
2 parents 107ea8b + 38a86f6 commit d20bf5c

12 files changed

Lines changed: 1040 additions & 54 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Critical Entity Soft Deletion and Audit Trail
2+
3+
Issue #1047 protects critical control-plane records from accidental,
4+
unaudited hard deletion.
5+
6+
## Scope
7+
8+
The audited lifecycle service covers:
9+
10+
- `Tenant`
11+
- persisted `ApiKey`
12+
13+
Existing `WebhookEndpoint` deletion remains a soft delete with
14+
`deletedAt`/`deletedBy` and admin audit logging. `ScopedAdminToken` uses
15+
revocation rather than deletion. Generic Prisma hard deletes are blocked
16+
outside tests for all four entity types and for `CriticalEntityAuditEvent`.
17+
18+
Financial records (`Transaction`, vault state, share-price snapshots, and
19+
reconciliation history) are deliberately excluded. They remain immutable and
20+
follow the regulatory retention policy rather than a reversible application
21+
delete workflow.
22+
23+
## Invariants
24+
25+
1. Delete and restore operations require a non-empty actor and reason.
26+
2. State changes and `CriticalEntityAuditEvent` creation occur in one database
27+
transaction.
28+
3. Conditional `updateMany` predicates make retries idempotent and prevent two
29+
concurrent requests from producing duplicate lifecycle events.
30+
4. Deleting a tenant atomically disables and soft-deletes all of its active API
31+
keys.
32+
5. Restoring a tenant does not restore credentials. Each API key requires a
33+
separate audited restore after the tenant is active.
34+
6. Authentication rejects keys whose key record or parent tenant is deleted.
35+
7. Audit metadata never includes plaintext credentials or stored key hashes.
36+
8. Audit rows are append-only. Generic Prisma `delete`/`deleteMany` operations
37+
against protected models throw outside the test environment.
38+
39+
Raw SQL bypasses Prisma safeguards and must not be used for lifecycle changes.
40+
Retention cleanup that eventually hard-deletes records must use a dedicated,
41+
reviewed maintenance path after the documented grace period.
42+
43+
## Service API
44+
45+
Use `src/criticalEntityLifecycle.ts`:
46+
47+
```ts
48+
await softDeleteTenant('tenant-id', {
49+
actor: 'admin@example.com',
50+
reason: 'Customer account closed',
51+
});
52+
53+
await restoreTenant('tenant-id', {
54+
actor: 'security-admin@example.com',
55+
reason: 'Closure reversed after verification',
56+
});
57+
58+
await softDeleteApiKey('api-key-id', {
59+
actor: 'security-admin@example.com',
60+
reason: 'Credential compromised',
61+
});
62+
```
63+
64+
The result status is one of:
65+
66+
- `changed`
67+
- `not_found`
68+
- `already_deleted`
69+
- `not_deleted`
70+
- `parent_deleted`
71+
72+
Treat statuses other than `changed` as no-op outcomes; they never create a
73+
duplicate audit event.
74+
75+
## Audit review
76+
77+
`listCriticalEntityAuditTrail` supports entity type, entity ID, and action
78+
filters. Results are newest-first and capped at 500 records per call.
79+
80+
An audit record contains:
81+
82+
- entity type and ID
83+
- `soft_delete` or `restore`
84+
- attributable actor
85+
- required reason
86+
- non-sensitive operation metadata
87+
- database-generated timestamp
88+
89+
Retain `CriticalEntityAuditEvent` records for seven years with other security
90+
audit data. They must not be changed or removed through application CRUD.
91+
92+
## Deployment
93+
94+
1. Back up the database.
95+
2. Apply migration
96+
`20260729000000_add_critical_entity_soft_delete`.
97+
3. Confirm the new nullable columns and audit table exist.
98+
4. Delete a non-production API key through the lifecycle service.
99+
5. Verify authentication fails immediately.
100+
6. Verify exactly one `CriticalEntityAuditEvent` exists.
101+
7. Restore the key and verify a second audit event is appended.
102+
103+
Rollback should restore application code first. The nullable columns and audit
104+
table are backward-compatible and should be retained until their data has been
105+
archived and a separate destructive migration is approved.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
-- Issue #1047: soft deletion and immutable lifecycle audits for critical
2+
-- control-plane entities. Financial history remains immutable and is not
3+
-- covered by this reversible deletion mechanism.
4+
-- migration-safety: allow-not-null-add
5+
-- migration-safety: allow-nonconcurrent-indexes
6+
-- The added columns are nullable. The NOT NULL detector otherwise scans into
7+
-- the later CREATE TABLE statement. SQLite does not support CONCURRENTLY.
8+
9+
-- Tenant and ApiKey existed in schema.prisma before they were represented in
10+
-- the migration history. Establish their baseline shape on clean databases;
11+
-- existing managed databases treat these statements as no-ops.
12+
CREATE TABLE IF NOT EXISTS "Tenant" (
13+
"id" TEXT NOT NULL PRIMARY KEY,
14+
"name" TEXT NOT NULL,
15+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
16+
);
17+
CREATE UNIQUE INDEX IF NOT EXISTS "Tenant_name_key" ON "Tenant"("name");
18+
CREATE INDEX IF NOT EXISTS "Tenant_name_idx" ON "Tenant"("name");
19+
20+
CREATE TABLE IF NOT EXISTS "ApiKey" (
21+
"id" TEXT NOT NULL PRIMARY KEY,
22+
"tenantId" TEXT NOT NULL,
23+
"hashedKey" TEXT NOT NULL,
24+
"role" TEXT NOT NULL,
25+
"scopes" TEXT NOT NULL DEFAULT '[]',
26+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
27+
"expiresAt" DATETIME,
28+
"isActive" BOOLEAN NOT NULL DEFAULT true,
29+
CONSTRAINT "ApiKey_tenantId_fkey"
30+
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE
31+
);
32+
CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");
33+
CREATE INDEX IF NOT EXISTS "ApiKey_tenantId_idx" ON "ApiKey"("tenantId");
34+
CREATE INDEX IF NOT EXISTS "ApiKey_role_idx" ON "ApiKey"("role");
35+
36+
ALTER TABLE "Tenant" ADD COLUMN "deletedAt" DATETIME;
37+
ALTER TABLE "Tenant" ADD COLUMN "deletedBy" TEXT;
38+
ALTER TABLE "Tenant" ADD COLUMN "deletionReason" TEXT;
39+
40+
ALTER TABLE "ApiKey" ADD COLUMN "deletedAt" DATETIME;
41+
ALTER TABLE "ApiKey" ADD COLUMN "deletedBy" TEXT;
42+
ALTER TABLE "ApiKey" ADD COLUMN "deletionReason" TEXT;
43+
44+
CREATE TABLE "CriticalEntityAuditEvent" (
45+
"id" TEXT NOT NULL PRIMARY KEY,
46+
"entityType" TEXT NOT NULL,
47+
"entityId" TEXT NOT NULL,
48+
"action" TEXT NOT NULL,
49+
"actor" TEXT NOT NULL,
50+
"reason" TEXT NOT NULL,
51+
"metadata" TEXT NOT NULL DEFAULT '{}',
52+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
53+
);
54+
55+
CREATE INDEX "Tenant_deletedAt_idx" ON "Tenant"("deletedAt");
56+
CREATE INDEX "ApiKey_tenantId_deletedAt_idx" ON "ApiKey"("tenantId", "deletedAt");
57+
CREATE INDEX "ApiKey_deletedAt_idx" ON "ApiKey"("deletedAt");
58+
CREATE INDEX "CriticalEntityAuditEvent_entityType_entityId_createdAt_idx"
59+
ON "CriticalEntityAuditEvent"("entityType", "entityId", "createdAt");
60+
CREATE INDEX "CriticalEntityAuditEvent_action_idx" ON "CriticalEntityAuditEvent"("action");
61+
CREATE INDEX "CriticalEntityAuditEvent_actor_idx" ON "CriticalEntityAuditEvent"("actor");
62+
CREATE INDEX "CriticalEntityAuditEvent_createdAt_idx" ON "CriticalEntityAuditEvent"("createdAt");

backend/prisma/schema.prisma

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -459,29 +459,57 @@ model ScopedAdminTokenRotationEvent {
459459

460460
// Tenant model – represents a customer/organization
461461
model Tenant {
462-
id String @id @default(uuid())
463-
name String @unique
464-
createdAt DateTime @default(now())
462+
id String @id @default(uuid())
463+
name String @unique
464+
createdAt DateTime @default(now())
465+
deletedAt DateTime?
466+
deletedBy String?
467+
deletionReason String?
465468
// Relations
466-
apiKeys ApiKey[]
469+
apiKeys ApiKey[]
470+
467471
@@index([name])
472+
@@index([deletedAt])
468473
}
469474

470475
// API key model – scoped per tenant with role and scopes
471476
model ApiKey {
472-
id String @id @default(uuid())
473-
tenantId String
474-
hashedKey String @unique
475-
role String
476-
scopes String @default("[]")
477-
createdAt DateTime @default(now())
478-
expiresAt DateTime?
479-
isActive Boolean @default(true)
480-
481-
tenant Tenant @relation(fields: [tenantId], references: [id])
477+
id String @id @default(uuid())
478+
tenantId String
479+
hashedKey String @unique
480+
role String
481+
scopes String @default("[]")
482+
createdAt DateTime @default(now())
483+
expiresAt DateTime?
484+
isActive Boolean @default(true)
485+
deletedAt DateTime?
486+
deletedBy String?
487+
deletionReason String?
488+
489+
tenant Tenant @relation(fields: [tenantId], references: [id])
482490
483491
@@index([tenantId])
492+
@@index([tenantId, deletedAt])
484493
@@index([role])
494+
@@index([deletedAt])
495+
}
496+
497+
/// Immutable lifecycle history for critical control-plane entities.
498+
/// Application code may append records but must never update or delete them.
499+
model CriticalEntityAuditEvent {
500+
id String @id @default(uuid())
501+
entityType String
502+
entityId String
503+
action String
504+
actor String
505+
reason String
506+
metadata String @default("{}")
507+
createdAt DateTime @default(now())
508+
509+
@@index([entityType, entityId, createdAt])
510+
@@index([action])
511+
@@index([actor])
512+
@@index([createdAt])
485513
}
486514

487515
// Durable write-ahead audit log for admin configuration changes (Issue #707).
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { isPersistedApiKeyUsable } from '../middleware/apiKeyAuth';
2+
3+
describe('persisted API key soft-delete authentication (Issue #1047)', () => {
4+
it('rejects a soft-deleted API key', () => {
5+
expect(
6+
isPersistedApiKeyUsable({
7+
isActive: true,
8+
deletedAt: new Date(),
9+
tenant: { deletedAt: null },
10+
})
11+
).toBe(false);
12+
});
13+
14+
it('rejects an otherwise active key when its tenant is deleted', () => {
15+
expect(
16+
isPersistedApiKeyUsable({
17+
isActive: true,
18+
deletedAt: null,
19+
tenant: { deletedAt: new Date() },
20+
})
21+
).toBe(false);
22+
});
23+
24+
it('accepts an active key only when its tenant is also active', () => {
25+
expect(
26+
isPersistedApiKeyUsable({
27+
isActive: true,
28+
deletedAt: null,
29+
tenant: { deletedAt: null },
30+
})
31+
).toBe(true);
32+
expect(
33+
isPersistedApiKeyUsable({
34+
isActive: false,
35+
deletedAt: null,
36+
tenant: { deletedAt: null },
37+
})
38+
).toBe(false);
39+
});
40+
});

0 commit comments

Comments
 (0)