@cosmicdrift/kumiko-bundled-features 0.274.0 → 0.276.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/__tests__/cross-tenant-jobs-escape-hatch.test.ts +45 -0
- package/src/audit/feature.ts +99 -95
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +21 -13
- package/src/auth-mfa/feature.ts +16 -1
- package/src/auth-mfa/handlers/reencrypt.job.ts +7 -9
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +7 -2
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +9 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +8 -2
- package/src/config/handlers/reencrypt.job.ts +8 -5
- package/src/crypto-shredding/feature.ts +18 -14
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +4 -1
- package/src/data-retention/feature.ts +21 -15
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +4 -2
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +8 -2
- package/src/files-tenant-data/changes.json +9 -1
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +8 -13
- package/src/files-tenant-data/index.ts +17 -8
- package/src/form-draft/feature.ts +13 -1
- package/src/form-draft/handlers/cleanup.job.ts +7 -11
- package/src/inbound-mail-foundation/feature.ts +18 -14
- package/src/jobs/__tests__/job-system-user.integration.test.ts +39 -30
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +3 -3
- package/src/jobs/handlers/retention-cleanup.job.ts +5 -3
- package/src/jobs/handlers/stale-run-sweep.job.ts +5 -3
- package/src/presets/__tests__/security-baseline.test.ts +60 -1
- package/src/presets/security-baseline.ts +1 -2
- package/src/rate-limiting/feature.ts +15 -11
- package/src/secrets/__tests__/rotate.integration.test.ts +4 -4
- package/src/secrets/changes.json +7 -0
- package/src/secrets/feature.ts +19 -4
- package/src/secrets/handlers/rotate.job.ts +7 -9
- package/src/sessions/__tests__/cleanup.integration.test.ts +5 -5
- package/src/sessions/feature.ts +142 -128
- package/src/sessions/handlers/cleanup.job.ts +7 -10
- package/src/tenant-lifecycle/feature.ts +13 -10
- package/src/tenant-settings/__tests__/tenant-currency-read-access.integration.test.ts +50 -0
- package/src/tenant-settings/constants.ts +6 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +4 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +11 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
- package/src/user-data-rights/feature.ts +26 -20
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +8 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +8 -2
- package/src/workflow-runner/feature.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.276.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.276.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.276.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.276.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.276.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.276.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.276.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.276.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.276.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// fw#2914 — JobContext.db is tenant-filtered; these bundled jobs reach every tenant
|
|
2
|
+
// and only work because their registration declares escapeHatch.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { createAuthMfaFeature } from "../auth-mfa/feature";
|
|
6
|
+
import { createDataRetentionFeature } from "../data-retention/feature";
|
|
7
|
+
import { createFilesTenantDataFeature } from "../files-tenant-data";
|
|
8
|
+
import { formDraftFeature } from "../form-draft/feature";
|
|
9
|
+
import { inboundMailFoundationFeature } from "../inbound-mail-foundation/feature";
|
|
10
|
+
import { createSecretsFeature } from "../secrets/feature";
|
|
11
|
+
import { createSessionsFeature } from "../sessions/feature";
|
|
12
|
+
import { createTenantLifecycleFeature } from "../tenant-lifecycle/feature";
|
|
13
|
+
import { createUserDataRightsFeature } from "../user-data-rights/feature";
|
|
14
|
+
|
|
15
|
+
const TEST_SECRET = "cross-tenant-jobs-escape-hatch-test-secret-0123456789";
|
|
16
|
+
|
|
17
|
+
const crossTenantJobs: ReadonlyArray<readonly [FeatureDefinition, string]> = [
|
|
18
|
+
[
|
|
19
|
+
createAuthMfaFeature({
|
|
20
|
+
setupTokenSecret: TEST_SECRET,
|
|
21
|
+
challengeTokenSecret: `${TEST_SECRET}-challenge`,
|
|
22
|
+
issuer: "Kumiko",
|
|
23
|
+
}),
|
|
24
|
+
"reencrypt",
|
|
25
|
+
],
|
|
26
|
+
[createDataRetentionFeature(), "retention-cleanup"],
|
|
27
|
+
[createFilesTenantDataFeature(), "sweep-orphaned-derivatives"],
|
|
28
|
+
[formDraftFeature, "cleanup"],
|
|
29
|
+
[inboundMailFoundationFeature, "inbound-mail-retention"],
|
|
30
|
+
[createSecretsFeature(), "rotate"],
|
|
31
|
+
[createSessionsFeature(), "cleanup"],
|
|
32
|
+
[createTenantLifecycleFeature(), "run-tenant-destruction"],
|
|
33
|
+
[createUserDataRightsFeature(), "run-export-jobs"],
|
|
34
|
+
[createUserDataRightsFeature(), "run-forget-cleanup"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe("bundled cross-tenant jobs declare escapeHatch (fw#2914)", () => {
|
|
38
|
+
for (const [feature, jobName] of crossTenantJobs) {
|
|
39
|
+
test(`${feature.name}:${jobName}`, () => {
|
|
40
|
+
const job = feature.jobs[jobName];
|
|
41
|
+
expect(job).toBeDefined();
|
|
42
|
+
expect(job?.escapeHatch?.reason.trim().length ?? 0).toBeGreaterThan(20);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
package/src/audit/feature.ts
CHANGED
|
@@ -23,108 +23,112 @@ import { AUDIT_I18N } from "./i18n";
|
|
|
23
23
|
// (tracked with the snapshot/archive infrastructure that already exists in
|
|
24
24
|
// the framework).
|
|
25
25
|
export function createAuditFeature(): FeatureDefinition {
|
|
26
|
-
return defineFeature(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
return defineFeature(
|
|
27
|
+
"audit",
|
|
28
|
+
(r) => {
|
|
29
|
+
r.describe(
|
|
30
|
+
"Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
31
|
+
);
|
|
32
|
+
r.uiHints({
|
|
33
|
+
displayLabel: "Audit Log",
|
|
34
|
+
category: "compliance",
|
|
35
|
+
recommended: false,
|
|
36
|
+
});
|
|
37
|
+
r.translations({ keys: AUDIT_I18N });
|
|
38
|
+
// Screens resolve actor names via tenant:query:members.
|
|
39
|
+
r.requires("tenant");
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
// Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
|
|
42
|
+
r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const queries = {
|
|
45
|
+
list: r.queryHandler(listQuery),
|
|
46
|
+
details: r.queryHandler(detailsQuery),
|
|
47
|
+
};
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{
|
|
53
|
-
field: "createdAt",
|
|
54
|
-
label: i18nKey("audit.log.col.when"),
|
|
55
|
-
renderer: { format: "timestamp" },
|
|
56
|
-
},
|
|
57
|
-
{ field: "type", label: i18nKey("audit.log.col.type") },
|
|
58
|
-
{ field: "createdBy", label: i18nKey("audit.log.col.actor") },
|
|
59
|
-
],
|
|
60
|
-
searchable: true,
|
|
61
|
-
defaultSort: { field: "createdAt", dir: "desc" },
|
|
62
|
-
rowActions: [
|
|
63
|
-
{
|
|
64
|
-
kind: "navigate",
|
|
65
|
-
id: "details",
|
|
66
|
-
label: i18nKey("audit.log.details"),
|
|
67
|
-
screen: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
68
|
-
entityId: "id",
|
|
69
|
-
rowClick: true,
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
pagination: "infinite",
|
|
73
|
-
description:
|
|
74
|
-
"Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
|
|
75
|
-
access: { roles: access.admin },
|
|
76
|
-
});
|
|
77
|
-
r.screen({
|
|
78
|
-
id: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
79
|
-
type: "projectionDetail",
|
|
80
|
-
query: AuditQueries.details,
|
|
81
|
-
idParam: "id",
|
|
82
|
-
fieldLabels: {
|
|
83
|
-
type: i18nKey("audit.log.col.type"),
|
|
84
|
-
createdAt: i18nKey("audit.log.col.when"),
|
|
85
|
-
aggregateType: i18nKey("audit.log.col.aggregateType"),
|
|
86
|
-
aggregateId: i18nKey("audit.log.col.aggregateId"),
|
|
87
|
-
createdBy: i18nKey("audit.log.col.actor"),
|
|
88
|
-
id: i18nKey("audit.log.detail.field.id"),
|
|
89
|
-
payload: i18nKey("audit.log.detail.payload"),
|
|
90
|
-
metadata: i18nKey("audit.log.detail.metadata"),
|
|
91
|
-
},
|
|
92
|
-
layout: {
|
|
93
|
-
sections: [
|
|
94
|
-
{
|
|
95
|
-
fields: [
|
|
96
|
-
"type",
|
|
97
|
-
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
98
|
-
"aggregateType",
|
|
99
|
-
"aggregateId",
|
|
100
|
-
"createdBy",
|
|
101
|
-
"id",
|
|
102
|
-
],
|
|
103
|
-
},
|
|
104
|
-
// No section `title` here (fw#2312 label-dedup fix): each section
|
|
105
|
-
// holds exactly one field, so a title would repeat the field's own
|
|
106
|
-
// label (rendered by RenderField as the Field's heading) verbatim.
|
|
49
|
+
r.screen({
|
|
50
|
+
id: AUDIT_LOG_SCREEN_ID,
|
|
51
|
+
type: "projectionList",
|
|
52
|
+
query: AuditQueries.list,
|
|
53
|
+
columns: [
|
|
107
54
|
{
|
|
108
|
-
|
|
55
|
+
field: "createdAt",
|
|
56
|
+
label: i18nKey("audit.log.col.when"),
|
|
57
|
+
renderer: { format: "timestamp" },
|
|
109
58
|
},
|
|
59
|
+
{ field: "type", label: i18nKey("audit.log.col.type") },
|
|
60
|
+
{ field: "createdBy", label: i18nKey("audit.log.col.actor") },
|
|
61
|
+
],
|
|
62
|
+
searchable: true,
|
|
63
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
64
|
+
rowActions: [
|
|
110
65
|
{
|
|
111
|
-
|
|
66
|
+
kind: "navigate",
|
|
67
|
+
id: "details",
|
|
68
|
+
label: i18nKey("audit.log.details"),
|
|
69
|
+
screen: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
70
|
+
entityId: "id",
|
|
71
|
+
rowClick: true,
|
|
112
72
|
},
|
|
113
73
|
],
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
74
|
+
pagination: "infinite",
|
|
75
|
+
description:
|
|
76
|
+
"Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
|
|
77
|
+
access: { roles: access.admin },
|
|
78
|
+
});
|
|
79
|
+
r.screen({
|
|
80
|
+
id: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
81
|
+
type: "projectionDetail",
|
|
82
|
+
query: AuditQueries.details,
|
|
83
|
+
idParam: "id",
|
|
84
|
+
fieldLabels: {
|
|
85
|
+
type: i18nKey("audit.log.col.type"),
|
|
86
|
+
createdAt: i18nKey("audit.log.col.when"),
|
|
87
|
+
aggregateType: i18nKey("audit.log.col.aggregateType"),
|
|
88
|
+
aggregateId: i18nKey("audit.log.col.aggregateId"),
|
|
89
|
+
createdBy: i18nKey("audit.log.col.actor"),
|
|
90
|
+
id: i18nKey("audit.log.detail.field.id"),
|
|
91
|
+
payload: i18nKey("audit.log.detail.payload"),
|
|
92
|
+
metadata: i18nKey("audit.log.detail.metadata"),
|
|
93
|
+
},
|
|
94
|
+
layout: {
|
|
95
|
+
sections: [
|
|
96
|
+
{
|
|
97
|
+
fields: [
|
|
98
|
+
"type",
|
|
99
|
+
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
100
|
+
"aggregateType",
|
|
101
|
+
"aggregateId",
|
|
102
|
+
"createdBy",
|
|
103
|
+
"id",
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
// No section `title` here (fw#2312 label-dedup fix): each section
|
|
107
|
+
// holds exactly one field, so a title would repeat the field's own
|
|
108
|
+
// label (rendered by RenderField as the Field's heading) verbatim.
|
|
109
|
+
{
|
|
110
|
+
fields: [{ field: "payload", renderer: { format: "json" } }],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
fields: [{ field: "metadata", renderer: { format: "json" } }],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
description:
|
|
118
|
+
"Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
|
|
119
|
+
listScreenId: AUDIT_LOG_SCREEN_ID,
|
|
120
|
+
access: { roles: access.admin },
|
|
121
|
+
});
|
|
122
|
+
r.nav({
|
|
123
|
+
id: "audit-log",
|
|
124
|
+
label: "audit:nav.auditLog",
|
|
125
|
+
icon: "file",
|
|
126
|
+
screen: "audit:screen:audit-log",
|
|
127
|
+
order: 30,
|
|
128
|
+
});
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
return { queries };
|
|
131
|
+
},
|
|
132
|
+
{ dedupeOptions: {} },
|
|
133
|
+
);
|
|
130
134
|
}
|
|
@@ -147,7 +147,7 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
|
|
|
147
147
|
}),
|
|
148
148
|
);
|
|
149
149
|
|
|
150
|
-
await mfaReencryptJob({}, jobCtx());
|
|
150
|
+
await mfaReencryptJob({}, jobCtx(), stack.db);
|
|
151
151
|
|
|
152
152
|
const afterJobRow = await readRawRow(user.id);
|
|
153
153
|
expect(JSON.parse(afterJobRow.totpSecret).kekVersion).toBe(2);
|
|
@@ -222,12 +222,16 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
|
|
|
222
222
|
child: () => capturingLog,
|
|
223
223
|
};
|
|
224
224
|
|
|
225
|
-
await mfaReencryptJob(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
225
|
+
await mfaReencryptJob(
|
|
226
|
+
{},
|
|
227
|
+
{
|
|
228
|
+
db: stack.db,
|
|
229
|
+
registry: stack.registry,
|
|
230
|
+
masterKeyProvider: mutableProvider,
|
|
231
|
+
log: capturingLog,
|
|
232
|
+
} as unknown as JobContext,
|
|
233
|
+
stack.db,
|
|
234
|
+
);
|
|
231
235
|
|
|
232
236
|
const completeLine = captured.info.find((line) =>
|
|
233
237
|
line.includes("[auth-mfa:reencrypt] complete:"),
|
|
@@ -296,12 +300,16 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
|
|
|
296
300
|
child: () => capturingLog,
|
|
297
301
|
};
|
|
298
302
|
|
|
299
|
-
await mfaReencryptJob(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
303
|
+
await mfaReencryptJob(
|
|
304
|
+
{},
|
|
305
|
+
{
|
|
306
|
+
db: stack.db,
|
|
307
|
+
registry: stack.registry,
|
|
308
|
+
masterKeyProvider: mutableProvider,
|
|
309
|
+
log: capturingLog,
|
|
310
|
+
} as unknown as JobContext,
|
|
311
|
+
stack.db,
|
|
312
|
+
);
|
|
305
313
|
|
|
306
314
|
const completeLine = captured.info.find((line) =>
|
|
307
315
|
line.includes("[auth-mfa:reencrypt] complete:"),
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { mfaRequiredConfigKey } from "./config";
|
|
3
4
|
import { createDisableHandler } from "./handlers/disable.write";
|
|
@@ -137,7 +138,21 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
137
138
|
// KEK-rotation for totpSecret (entity-field encryption). Manual
|
|
138
139
|
// trigger — ops runs it once after adding a new master key version,
|
|
139
140
|
// same operator workflow as config's own reencrypt job.
|
|
140
|
-
r.job(
|
|
141
|
+
r.job({
|
|
142
|
+
name: "reencrypt",
|
|
143
|
+
trigger: { manual: true },
|
|
144
|
+
escapeHatch: {
|
|
145
|
+
reason: "re-encrypts MFA secrets of every tenant's users with the new master key",
|
|
146
|
+
},
|
|
147
|
+
handler: (payload, ctx) =>
|
|
148
|
+
mfaReencryptJob(
|
|
149
|
+
payload,
|
|
150
|
+
ctx,
|
|
151
|
+
ctx.db.unsafeRaw(
|
|
152
|
+
"re-encrypts MFA secrets of every tenant's users with the new master key",
|
|
153
|
+
) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
154
|
+
),
|
|
155
|
+
});
|
|
141
156
|
|
|
142
157
|
r.entity("user-mfa", userMfaEntity);
|
|
143
158
|
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type DbConnection,
|
|
37
37
|
type TenantDb,
|
|
38
38
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
39
|
-
import type {
|
|
39
|
+
import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
40
40
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
41
41
|
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
42
42
|
import {
|
|
@@ -70,7 +70,11 @@ export type MfaReencryptJobResult = {
|
|
|
70
70
|
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
export
|
|
73
|
+
export async function mfaReencryptJob(
|
|
74
|
+
rawPayload: Record<string, unknown>,
|
|
75
|
+
ctx: JobContext,
|
|
76
|
+
db: DbConnection,
|
|
77
|
+
): Promise<void> {
|
|
74
78
|
const payload = rawPayload as MfaReencryptJobPayload; // @cast-boundary engine-payload
|
|
75
79
|
const maybeCipher = configuredEntityFieldEncryption();
|
|
76
80
|
if (!maybeCipher) {
|
|
@@ -91,12 +95,6 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
91
95
|
});
|
|
92
96
|
}
|
|
93
97
|
const provider = ctx.masterKeyProvider;
|
|
94
|
-
if (!ctx.db) {
|
|
95
|
-
throw new InternalError({
|
|
96
|
-
message: "[auth-mfa:reencrypt] ctx.db missing — job context requires a database connection.",
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
100
98
|
// Undefined = per-subject crypto-shredding isn't configured for this app —
|
|
101
99
|
// both fields are then plain envelope strings, no PII layer to peel.
|
|
102
100
|
const piiKms = configuredPiiSubjectKms();
|
|
@@ -222,4 +220,4 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
222
220
|
stoppedReason: outcome.stoppedReason,
|
|
223
221
|
};
|
|
224
222
|
ctx.log?.info?.(`[auth-mfa:reencrypt] complete: ${JSON.stringify(result)}`);
|
|
225
|
-
}
|
|
223
|
+
}
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
19
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
createUncheckedSystemDb,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
21
25
|
import {
|
|
22
26
|
access,
|
|
23
27
|
createSystemConfig,
|
|
@@ -152,7 +156,8 @@ function racyJobCtx(
|
|
|
152
156
|
},
|
|
153
157
|
};
|
|
154
158
|
return {
|
|
155
|
-
db: stack.db,
|
|
159
|
+
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
160
|
+
systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
|
|
156
161
|
registry: stack.registry,
|
|
157
162
|
masterKeyProvider: mutableProvider,
|
|
158
163
|
configEncryption: racyCipher,
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createEventStoreExecutor,
|
|
15
|
+
createTenantDb,
|
|
16
|
+
createUncheckedSystemDb,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
14
18
|
import {
|
|
15
19
|
access,
|
|
16
20
|
createSystemConfig,
|
|
@@ -134,13 +138,15 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
134
138
|
|
|
135
139
|
function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
136
140
|
const log = captured ? capturingLog(captured) : noopLog;
|
|
141
|
+
const systemModeDb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
|
|
137
142
|
return {
|
|
138
|
-
db:
|
|
143
|
+
db: systemModeDb,
|
|
144
|
+
systemDb: createUncheckedSystemDb(systemModeDb),
|
|
139
145
|
registry: stack.registry,
|
|
140
146
|
masterKeyProvider: mutableProvider,
|
|
141
147
|
configEncryption: cipher,
|
|
142
148
|
log,
|
|
143
|
-
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
|
|
149
|
+
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/systemDb/registry/masterKeyProvider/configEncryption/log
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
beforeAll(async () => {
|
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createEventStoreExecutor,
|
|
17
|
+
createTenantDb,
|
|
18
|
+
createUncheckedSystemDb,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
16
20
|
import {
|
|
17
21
|
access,
|
|
18
22
|
createSystemUser,
|
|
19
23
|
createTenantConfig,
|
|
20
24
|
defineFeature,
|
|
25
|
+
SYSTEM_TENANT_ID,
|
|
21
26
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
27
|
import { ESCAPE_HATCH_USED_SIGNAL } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
23
28
|
import {
|
|
@@ -111,7 +116,8 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
111
116
|
|
|
112
117
|
function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
113
118
|
return {
|
|
114
|
-
db: stack.db,
|
|
119
|
+
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
120
|
+
systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
|
|
115
121
|
registry: stack.registry,
|
|
116
122
|
masterKeyProvider: mutableProvider,
|
|
117
123
|
configEncryption: cipher,
|
|
@@ -87,17 +87,20 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
87
87
|
"[config:reencrypt] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
if (!ctx.
|
|
90
|
+
if (!ctx.registry) {
|
|
91
91
|
throw new InternalError({
|
|
92
|
-
message: "[config:reencrypt] ctx.
|
|
92
|
+
message: "[config:reencrypt] ctx.registry missing — job context requires the registry.",
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// config is r.systemScope(), so ctx.systemDb is always present on its jobs.
|
|
96
|
+
if (!ctx.systemDb) {
|
|
96
97
|
throw new InternalError({
|
|
97
|
-
message: "[config:reencrypt] ctx.
|
|
98
|
+
message: "[config:reencrypt] ctx.systemDb missing on a system-scoped job",
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
|
-
const db = ctx.
|
|
101
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
102
|
+
"system-wide KEK rotation scans every tenant's encrypted config values",
|
|
103
|
+
) as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
101
104
|
|
|
102
105
|
const encryptedKeys = [...ctx.registry.getAllConfigKeys()]
|
|
103
106
|
.filter(([, def]) => def.encrypted === true)
|
|
@@ -7,19 +7,23 @@ import {
|
|
|
7
7
|
} from "./handlers/forget-subject.write";
|
|
8
8
|
|
|
9
9
|
export function createCryptoShreddingFeature(): FeatureDefinition {
|
|
10
|
-
return defineFeature(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
return defineFeature(
|
|
11
|
+
CRYPTO_SHREDDING_FEATURE_NAME,
|
|
12
|
+
(r) => {
|
|
13
|
+
r.describe(
|
|
14
|
+
"Operator-level crypto-shredding trigger. `forget-subject` erases a user or tenant subject key in the configured KMS adapter, making every PII field encrypted under it permanently unreadable (reads render the `[[erased]]` sentinel), and appends a `subject-forgotten` audit event. Requires a KMS adapter (`runProdApp({ kms })`). The automated Art.-17 deletion pipeline in `user-data-rights` erases keys itself; this command covers manual forgets (authority requests, operator recovery, tenant destroy).",
|
|
15
|
+
);
|
|
16
|
+
r.uiHints({
|
|
17
|
+
displayLabel: "Crypto-Shredding",
|
|
18
|
+
category: "compliance",
|
|
19
|
+
});
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// `reason` is operator free text and may name the subject; encrypting it under
|
|
22
|
+
// the subject key is pointless (that key is being erased) — see #2776.
|
|
23
|
+
r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
|
|
24
|
+
r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
|
|
25
|
+
r.writeHandler(forgetSubjectWrite);
|
|
26
|
+
},
|
|
27
|
+
{ dedupeOptions: {} },
|
|
28
|
+
);
|
|
25
29
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
17
|
import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import {
|
|
@@ -278,7 +279,9 @@ describe("runRetentionCleanup :: real postgres", () => {
|
|
|
278
279
|
if (!job) return;
|
|
279
280
|
|
|
280
281
|
const ctx: JobContext = {
|
|
281
|
-
db: stack.db,
|
|
282
|
+
db: createTenantDb(stack.db, T1, "tenant", undefined, undefined, undefined, {
|
|
283
|
+
unsafeRaw: { reason: "per-tenant fan-out cleanup using raw helpers" },
|
|
284
|
+
}),
|
|
282
285
|
registry: stack.registry,
|
|
283
286
|
systemUser: { id: "system", tenantId: T1, roles: ["all"] },
|
|
284
287
|
log: noopLogger,
|
|
@@ -71,19 +71,23 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
71
71
|
r.exposesApi("retention.policyFor");
|
|
72
72
|
r.queryHandler(policyForQuery);
|
|
73
73
|
|
|
74
|
-
// S2.D2b —
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
r.job(
|
|
80
|
-
"retention-cleanup",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
// S2.D2b — autonomous retention cleanup. perTenant fan-out (one run per
|
|
75
|
+
// active tenant, like soft-delete-cleanup): runRetentionCleanup's raw
|
|
76
|
+
// query helpers need the unfiltered runner, so the handler declares
|
|
77
|
+
// escapeHatch and reaches for ctx.db.unsafeRaw(). Without this cron,
|
|
78
|
+
// retention rules are configured but never enforced.
|
|
79
|
+
r.job({
|
|
80
|
+
name: "retention-cleanup",
|
|
81
|
+
trigger: { cron: "0 3 * * *" },
|
|
82
|
+
perTenant: true,
|
|
83
|
+
concurrency: "skip",
|
|
84
|
+
escapeHatch: {
|
|
85
|
+
reason:
|
|
86
|
+
"retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId",
|
|
87
|
+
},
|
|
88
|
+
handler: async (_payload, ctx) => {
|
|
89
|
+
if (!ctx.registry) {
|
|
90
|
+
throw new Error("retention-cleanup: ctx.registry required (JobContext incomplete)");
|
|
87
91
|
}
|
|
88
92
|
const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
|
|
89
93
|
if (tenantId === undefined) {
|
|
@@ -91,7 +95,9 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
91
95
|
return;
|
|
92
96
|
}
|
|
93
97
|
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
94
|
-
const cleanupDb = ctx.db
|
|
98
|
+
const cleanupDb = ctx.db.unsafeRaw(
|
|
99
|
+
"retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId",
|
|
100
|
+
) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
95
101
|
const tenantPreset = await resolveTenantRetentionPreset({
|
|
96
102
|
db: cleanupDb,
|
|
97
103
|
registry: ctx.registry,
|
|
@@ -111,6 +117,6 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
111
117
|
);
|
|
112
118
|
}
|
|
113
119
|
},
|
|
114
|
-
);
|
|
120
|
+
});
|
|
115
121
|
});
|
|
116
122
|
}
|