@cosmicdrift/kumiko-bundled-features 0.234.0 → 0.235.1
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/auth-email-password/__tests__/auth.integration.test.ts +52 -0
- package/src/auth-email-password/seeding.ts +7 -5
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +64 -2
- package/src/cap-counter/feature.ts +8 -3
- package/src/cap-counter/i18n.ts +1 -0
- package/src/config/i18n.ts +2 -7
- package/src/config/web/i18n.ts +11 -0
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +90 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +84 -15
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +38 -1
- package/src/jobs/handlers/retry.write.ts +8 -1
- package/src/legal-pages/README.md +4 -2
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +16 -5
- package/src/legal-pages/feature.ts +4 -1
- package/src/secrets/__tests__/settings-hub-i18n.test.ts +27 -0
- package/src/secrets/feature.ts +10 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +30 -2
- package/src/tenant/constants.ts +5 -3
- package/src/tenant/screens.ts +6 -1
- package/src/user/handlers/create.write.ts +2 -0
- package/src/user/handlers/find-for-auth.query.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +62 -0
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +145 -1
- package/src/workflow-runner/event-trigger.ts +11 -5
- package/src/workflow-runner/handlers/resume-run.write.ts +94 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.1",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.1",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.235.1",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.235.1",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.235.1",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.235.1",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.235.1",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.1",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.235.1"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import {
|
|
5
|
+
configureBlindIndexKey,
|
|
6
|
+
configurePiiSubjectKms,
|
|
7
|
+
InMemoryKmsAdapter,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
9
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
10
|
import {
|
|
6
11
|
createTestUser,
|
|
@@ -16,6 +21,9 @@ import {
|
|
|
16
21
|
expectErrorIncludes,
|
|
17
22
|
getSetCookieRaw,
|
|
18
23
|
getSetCookieValue,
|
|
24
|
+
resetBlindIndexKeyForTests,
|
|
25
|
+
resetPiiSubjectKmsForTests,
|
|
26
|
+
updateRows,
|
|
19
27
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
20
28
|
import { createConfigFeature } from "../../config";
|
|
21
29
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -215,6 +223,50 @@ describe("scenario 3: login without membership", () => {
|
|
|
215
223
|
});
|
|
216
224
|
});
|
|
217
225
|
|
|
226
|
+
// --- Scenario 3b: login resolves the live account when its email is shared
|
|
227
|
+
// with a soft-deleted row (fw#2464 partial bidx index only covers live rows) ---
|
|
228
|
+
|
|
229
|
+
describe("scenario 3b: login with an email shared by a soft-deleted row", () => {
|
|
230
|
+
// The DB-level dedup that lets a soft-deleted row and a live row share an
|
|
231
|
+
// email only exists on the blind-index column (see
|
|
232
|
+
// email-unique-blind-index.integration.test.ts) — without a configured
|
|
233
|
+
// key, email_bidx stays NULL and the plaintext-fallback unique index
|
|
234
|
+
// blocks the second create outright, never reaching the login path.
|
|
235
|
+
beforeAll(() => {
|
|
236
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
237
|
+
configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
afterAll(() => {
|
|
241
|
+
resetPiiSubjectKmsForTests();
|
|
242
|
+
resetBlindIndexKeyForTests();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("new account wins the login, not the soft-deleted original", async () => {
|
|
246
|
+
const original = await seedLoginUser({
|
|
247
|
+
email: "reused-login@example.com",
|
|
248
|
+
password: "original-password-123",
|
|
249
|
+
});
|
|
250
|
+
await updateRows(stack.db, userTable, { isDeleted: true }, { id: original.id });
|
|
251
|
+
|
|
252
|
+
const recreated = await seedLoginUser({
|
|
253
|
+
email: "reused-login@example.com",
|
|
254
|
+
password: "new-account-password-123",
|
|
255
|
+
});
|
|
256
|
+
expect(recreated.id).not.toBe(original.id);
|
|
257
|
+
|
|
258
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
259
|
+
email: "reused-login@example.com",
|
|
260
|
+
password: "new-account-password-123",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(200);
|
|
264
|
+
const body = await res.json();
|
|
265
|
+
expect(body.isSuccess).toBe(true);
|
|
266
|
+
expect(body.user.id).toBe(recreated.id);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
218
270
|
// --- Scenario 4 + 5: change-password flow ---
|
|
219
271
|
|
|
220
272
|
describe("scenario 4: change-password with wrong old password", () => {
|
|
@@ -139,11 +139,13 @@ export async function provisionSignupAccount(
|
|
|
139
139
|
// create paths (#1478 — this was only wired for tenant before).
|
|
140
140
|
hooks?: SeedTenantHooks,
|
|
141
141
|
): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
|
|
142
|
-
// Create-only
|
|
143
|
-
//
|
|
144
|
-
// add-only)
|
|
145
|
-
//
|
|
146
|
-
|
|
142
|
+
// Create-only guard BEFORE seedTenant: hard-abort on an already-registered
|
|
143
|
+
// email, otherwise an orphaned tenant would be created and seedUser
|
|
144
|
+
// (idempotent add-only) would silently return the existing userId ->
|
|
145
|
+
// confirm mints a session for the wrong account (#365).
|
|
146
|
+
// isDeleted: false — the partial bidx unique index only covers live rows,
|
|
147
|
+
// so a soft-deleted row must not block re-registering its email.
|
|
148
|
+
const existingUser = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
|
|
147
149
|
if (existingUser) {
|
|
148
150
|
throw new ConflictError({ message: "signup: email already registered" });
|
|
149
151
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
createTestUser,
|
|
4
4
|
setupTestStack,
|
|
5
5
|
type TestStack,
|
|
6
|
+
testTenantId,
|
|
6
7
|
unsafeCreateEntityTable,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
8
9
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
9
|
-
import { CAP_COUNTER_LIST_SCREEN_ID, CapCounterQueries } from "../constants";
|
|
10
|
+
import { CAP_COUNTER_LIST_SCREEN_ID, CapCounterHandlers, CapCounterQueries } from "../constants";
|
|
10
11
|
import { capCounterEntity } from "../entity";
|
|
11
12
|
import { capCounterFeature } from "../feature";
|
|
12
13
|
|
|
@@ -23,6 +24,14 @@ afterAll(async () => {
|
|
|
23
24
|
await stack.cleanup();
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
function tenantAdminFor(tenantNumber: number) {
|
|
28
|
+
return createTestUser({
|
|
29
|
+
id: tenantNumber,
|
|
30
|
+
tenantId: testTenantId(tenantNumber),
|
|
31
|
+
roles: ["TenantAdmin"],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
describe("cap-counter access matrix", () => {
|
|
27
36
|
test("cap-list screen and list query are SystemAdmin-only", () => {
|
|
28
37
|
expect(rolesOf(stack.registry.getQueryHandler(CapCounterQueries.list)?.access)).toEqual([
|
|
@@ -51,3 +60,56 @@ describe("cap-counter list HTTP access", () => {
|
|
|
51
60
|
expect((await stack.http.query(CapCounterQueries.list, {}, user)).status).toBe(403);
|
|
52
61
|
});
|
|
53
62
|
});
|
|
63
|
+
|
|
64
|
+
describe("cap-counter list cross-tenant read", () => {
|
|
65
|
+
const CAP_NAME = "cross-tenant-list-probe";
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
// increment access is SystemAdmin-only (see increment.write.ts) — tenant
|
|
69
|
+
// scoping on the write side comes from the actor's tenantId, not the
|
|
70
|
+
// role, so a SystemAdmin actor per seed-tenant still writes into that
|
|
71
|
+
// tenant's own row.
|
|
72
|
+
const seedTenantA = createTestUser({
|
|
73
|
+
id: 201,
|
|
74
|
+
tenantId: testTenantId(201),
|
|
75
|
+
roles: ["SystemAdmin"],
|
|
76
|
+
});
|
|
77
|
+
const seedTenantB = createTestUser({
|
|
78
|
+
id: 202,
|
|
79
|
+
tenantId: testTenantId(202),
|
|
80
|
+
roles: ["SystemAdmin"],
|
|
81
|
+
});
|
|
82
|
+
await stack.http.writeOk(
|
|
83
|
+
CapCounterHandlers.increment,
|
|
84
|
+
{ capName: CAP_NAME, amount: 11, periodStartIso: "2026-05-01T00:00:00Z" },
|
|
85
|
+
seedTenantA,
|
|
86
|
+
);
|
|
87
|
+
await stack.http.writeOk(
|
|
88
|
+
CapCounterHandlers.increment,
|
|
89
|
+
{ capName: CAP_NAME, amount: 22, periodStartIso: "2026-05-01T00:00:00Z" },
|
|
90
|
+
seedTenantB,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("SystemAdmin sees rows from BOTH tenants, not just its own", async () => {
|
|
95
|
+
const sysadmin = createTestUser({ id: 43, roles: ["SystemAdmin"] });
|
|
96
|
+
const res = await stack.http.queryOk<{ rows: readonly Record<string, unknown>[] }>(
|
|
97
|
+
CapCounterQueries.list,
|
|
98
|
+
{ filter: { field: "capName", op: "eq", value: CAP_NAME } },
|
|
99
|
+
sysadmin,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const tenantIds = res.rows.map((row) => row["tenantId"]);
|
|
103
|
+
expect(tenantIds).toContain(testTenantId(201));
|
|
104
|
+
expect(tenantIds).toContain(testTenantId(202));
|
|
105
|
+
// Neither seeded tenant matches the querying SystemAdmin's own
|
|
106
|
+
// tenantId — proves this isn't accidentally passing via tenant-match.
|
|
107
|
+
expect(sysadmin.tenantId).not.toBe(testTenantId(201));
|
|
108
|
+
expect(sysadmin.tenantId).not.toBe(testTenantId(202));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("TenantAdmin still gets 403 on list (cross-tenant read stays SystemAdmin-only)", async () => {
|
|
112
|
+
const tenantAdmin = tenantAdminFor(201);
|
|
113
|
+
expect((await stack.http.query(CapCounterQueries.list, {}, tenantAdmin)).status).toBe(403);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
// config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
|
|
47
47
|
// vom Framework-Default (Base-Column tenantId).
|
|
48
48
|
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
access,
|
|
51
|
+
defineEntityListHandler,
|
|
52
|
+
defineFeature,
|
|
53
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
50
54
|
import {
|
|
51
55
|
CAP_COUNTER_FEATURE,
|
|
52
56
|
CAP_COUNTER_LIST_SCREEN_ID,
|
|
@@ -62,7 +66,7 @@ import {
|
|
|
62
66
|
import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
|
|
63
67
|
import { CAP_COUNTER_I18N } from "./i18n";
|
|
64
68
|
|
|
65
|
-
const sysadminAccess = { access: { roles:
|
|
69
|
+
const sysadminAccess = { access: { roles: access.systemAdmin }, crossTenant: true } as const;
|
|
66
70
|
|
|
67
71
|
export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
68
72
|
r.describe(
|
|
@@ -101,7 +105,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
101
105
|
id: CAP_COUNTER_LIST_SCREEN_ID,
|
|
102
106
|
type: "entityList",
|
|
103
107
|
entity: "cap-counter",
|
|
104
|
-
columns: ["capName", "value", "periodStart", "lastSoftWarnedAt"],
|
|
108
|
+
columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
|
|
105
109
|
defaultSort: { field: "capName", dir: "asc" },
|
|
106
110
|
searchable: true,
|
|
107
111
|
access: { roles: ["SystemAdmin"] },
|
|
@@ -117,6 +121,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
117
121
|
keys: {
|
|
118
122
|
...CAP_COUNTER_I18N,
|
|
119
123
|
"cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
|
|
124
|
+
"cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
|
|
120
125
|
},
|
|
121
126
|
});
|
|
122
127
|
});
|
package/src/cap-counter/i18n.ts
CHANGED
|
@@ -2,6 +2,7 @@ type LocalizedString = { readonly en: string };
|
|
|
2
2
|
|
|
3
3
|
export const CAP_COUNTER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:cap-list.title": { en: "Usage caps" },
|
|
5
|
+
"cap-counter:entity:cap-counter:field:tenantId": { en: "Tenant" },
|
|
5
6
|
"cap-counter:entity:cap-counter:field:capName": { en: "Cap" },
|
|
6
7
|
"cap-counter:entity:cap-counter:field:value": { en: "Value" },
|
|
7
8
|
"cap-counter:entity:cap-counter:field:periodStart": { en: "Period start" },
|
package/src/config/i18n.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
2
|
|
|
3
3
|
/** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
|
|
4
|
-
export const CONFIG_FEATURE_I18N
|
|
5
|
-
"config.settings.title": { en: "Settings" },
|
|
6
|
-
"config.settings.system": { en: "Platform" },
|
|
7
|
-
"config.settings.tenant": { en: "Tenant" },
|
|
8
|
-
"config.settings.user": { en: "Personal" },
|
|
9
|
-
};
|
|
4
|
+
export const CONFIG_FEATURE_I18N = SETTINGS_HUB_I18N;
|
package/src/config/web/i18n.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
11
11
|
|
|
12
12
|
export const defaultTranslations: TranslationsByLocale = {
|
|
13
13
|
en: {
|
|
14
|
+
"config.secrets.delete": "Delete",
|
|
15
|
+
"config.secrets.notSet": "Not set",
|
|
16
|
+
"config.secrets.placeholder": "Enter a value",
|
|
17
|
+
"config.secrets.replacePlaceholder": "Enter a new value to replace",
|
|
18
|
+
"config.secrets.required": "Required",
|
|
19
|
+
"config.secrets.set": "Set",
|
|
20
|
+
"config.secrets.title": "Secrets",
|
|
14
21
|
"config.settings.title": "Settings",
|
|
15
22
|
"config.settings.system": "Platform",
|
|
16
23
|
"config.settings.tenant": "Tenant",
|
|
@@ -18,5 +25,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
18
25
|
"config.errors.systemOnly": "This value can only be set by the system.",
|
|
19
26
|
"config.errors.invalidScope": "This scope is not allowed for this key.",
|
|
20
27
|
"config.errors.unknownKey": "Unknown configuration key.",
|
|
28
|
+
// Required by every generated screen (screenTitleKey, required-surface-keys.ts) —
|
|
29
|
+
// the secrets screen has a fixed id ("secrets"), so the framework ships its
|
|
30
|
+
// title translation directly instead of asking every app to declare it.
|
|
31
|
+
"screen:secrets.title": "Secrets",
|
|
21
32
|
},
|
|
22
33
|
};
|
|
@@ -46,7 +46,7 @@ import { seedTenantMembership } from "../../tenant/seeding";
|
|
|
46
46
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
47
47
|
import { createUserFeature } from "../../user/feature";
|
|
48
48
|
import { seedUser } from "../../user/seeding";
|
|
49
|
-
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
49
|
+
import { SUBJECT_FORGET_DENIED_EVENT_NAME, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
50
50
|
import { createCryptoShreddingFeature } from "../feature";
|
|
51
51
|
|
|
52
52
|
const FORGET = "crypto-shredding:write:forget-subject";
|
|
@@ -96,6 +96,17 @@ async function forgottenEvents(): Promise<Array<{ payload: Record<string, unknow
|
|
|
96
96
|
})) as Array<{ payload: Record<string, unknown> }>;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Parameterized (unlike forgottenEvents) because the denial-audit tests
|
|
100
|
+
// below run against both this file's top-level stack and the separate
|
|
101
|
+
// tenant+user-mounted stack further down — each has its own events table.
|
|
102
|
+
async function deniedEvents(
|
|
103
|
+
db: TestStack["db"] = stack.db,
|
|
104
|
+
): Promise<Array<{ payload: Record<string, unknown> }>> {
|
|
105
|
+
return (await selectMany(db, eventsTable, {
|
|
106
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
107
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
describe("crypto-shredding :: forget-subject", () => {
|
|
100
111
|
test("DPO forgets a user subject → key erased + audit event", async () => {
|
|
101
112
|
const subject = { kind: "user", userId: TARGET_USER_ID } as const;
|
|
@@ -150,6 +161,26 @@ describe("crypto-shredding :: forget-subject", () => {
|
|
|
150
161
|
).resolves.toBeTruthy();
|
|
151
162
|
});
|
|
152
163
|
|
|
164
|
+
// fw#2592 regression: the denial audit event used to be appended via
|
|
165
|
+
// ctx.unsafeAppendEvent, inside the same tx the denial then rolled back —
|
|
166
|
+
// the store stayed empty even though a cross-tenant probe was denied.
|
|
167
|
+
test("cross-tenant denial (kind=tenant) still leaves an audit event that survives the rollback", async () => {
|
|
168
|
+
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
169
|
+
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
170
|
+
|
|
171
|
+
const err = await stack.http.writeErr(FORGET, { subject, reason: REASON }, dpoUser);
|
|
172
|
+
expect(err.httpStatus).toBe(403);
|
|
173
|
+
|
|
174
|
+
const denied = await deniedEvents();
|
|
175
|
+
expect(denied).toHaveLength(1);
|
|
176
|
+
expect(denied[0]?.payload).toMatchObject({
|
|
177
|
+
subjectKind: "tenant",
|
|
178
|
+
reason: REASON,
|
|
179
|
+
forgottenBy: dpoUser.id,
|
|
180
|
+
actorTenantId: dpoUser.tenantId,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
153
184
|
test("SystemAdmin bypasses the tenant-scope guard", async () => {
|
|
154
185
|
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
155
186
|
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
@@ -479,6 +510,64 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
479
510
|
expect(userRow?.["status"]).not.toBe(USER_STATUS.Deleted);
|
|
480
511
|
});
|
|
481
512
|
|
|
513
|
+
// fw#2592 regression, kind=user variant of the top-level "cross-tenant
|
|
514
|
+
// denial ... survives the rollback" test: before the fix, deniedEvents()
|
|
515
|
+
// would come back empty here too.
|
|
516
|
+
test("cross-tenant denial (kind=user) still leaves an audit event that survives the rollback", async () => {
|
|
517
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
518
|
+
email: "foreign-tenant-user-2592@example.com",
|
|
519
|
+
displayName: "Foreign Tenant User 2592",
|
|
520
|
+
emailVerified: true,
|
|
521
|
+
});
|
|
522
|
+
await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
|
|
523
|
+
|
|
524
|
+
const err = await stack.http.writeErr(
|
|
525
|
+
FORGET,
|
|
526
|
+
{ subject: { kind: "user", userId }, reason: REASON },
|
|
527
|
+
dpoUser,
|
|
528
|
+
);
|
|
529
|
+
expect(err.httpStatus).toBe(403);
|
|
530
|
+
|
|
531
|
+
const denied = await deniedEvents(stack.db);
|
|
532
|
+
expect(denied).toHaveLength(1);
|
|
533
|
+
expect(denied[0]?.payload).toMatchObject({
|
|
534
|
+
subjectKind: "user",
|
|
535
|
+
reason: REASON,
|
|
536
|
+
forgottenBy: dpoUser.id,
|
|
537
|
+
actorTenantId: dpoUser.tenantId,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// fw#2591 payload-hardening coverage (previously untested): the denial
|
|
542
|
+
// event must carry only a digest, never the foreign user id or a
|
|
543
|
+
// plaintext subject key.
|
|
544
|
+
test("denial payload carries only the digest, never the foreign subject id or a plaintext key", async () => {
|
|
545
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
546
|
+
email: "foreign-tenant-user-2591@example.com",
|
|
547
|
+
displayName: "Foreign Tenant User 2591",
|
|
548
|
+
emailVerified: true,
|
|
549
|
+
});
|
|
550
|
+
await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
|
|
551
|
+
|
|
552
|
+
await stack.http.writeErr(
|
|
553
|
+
FORGET,
|
|
554
|
+
{ subject: { kind: "user", userId }, reason: REASON },
|
|
555
|
+
dpoUser,
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
const denied = await deniedEvents(stack.db);
|
|
559
|
+
expect(denied).toHaveLength(1);
|
|
560
|
+
const payload = denied[0]?.payload ?? {};
|
|
561
|
+
expect(payload["subjectKeyDigest"]).toBeTruthy();
|
|
562
|
+
expect(payload["subjectKind"]).toBe("user");
|
|
563
|
+
expect(payload).not.toHaveProperty("subjectKey");
|
|
564
|
+
expect(payload["actorTenantId"]).toBe(dpoUser.tenantId);
|
|
565
|
+
|
|
566
|
+
const serialized = JSON.stringify(payload);
|
|
567
|
+
expect(serialized).not.toContain(userId);
|
|
568
|
+
expect(serialized).not.toContain(TENANT_B);
|
|
569
|
+
});
|
|
570
|
+
|
|
482
571
|
test("SystemAdmin can forget a user with no membership in any tenant", async () => {
|
|
483
572
|
const { id: userId } = await seedUser(stack.db, {
|
|
484
573
|
email: "system-admin-forgets@example.com",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
3
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
4
|
import {
|
|
@@ -13,8 +14,10 @@ import {
|
|
|
13
14
|
import {
|
|
14
15
|
defineWriteHandler,
|
|
15
16
|
type FeatureDefinition,
|
|
17
|
+
type HandlerContext,
|
|
16
18
|
type SessionUser,
|
|
17
19
|
type TenantId,
|
|
20
|
+
type WriteEvent,
|
|
18
21
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
22
|
import {
|
|
20
23
|
AccessDeniedError,
|
|
@@ -22,7 +25,9 @@ import {
|
|
|
22
25
|
type WriteFailure,
|
|
23
26
|
writeFailure,
|
|
24
27
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
29
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
30
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
26
31
|
import { z } from "zod";
|
|
27
32
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
|
|
28
33
|
import { USER_STATUS } from "../../user";
|
|
@@ -55,7 +60,8 @@ export const subjectForgottenSchema = z.object({
|
|
|
55
60
|
});
|
|
56
61
|
|
|
57
62
|
export const subjectForgetDeniedSchema = z.object({
|
|
58
|
-
|
|
63
|
+
subjectKeyDigest: z.string().min(1),
|
|
64
|
+
subjectKind: z.enum(["user", "tenant"]),
|
|
59
65
|
reason: z.string().min(10),
|
|
60
66
|
forgottenBy: z.string().min(1),
|
|
61
67
|
actorTenantId: z.string().min(1),
|
|
@@ -99,6 +105,75 @@ async function resolveTenantScopeDenial(
|
|
|
99
105
|
return ownedInTenant ? undefined : memberDenied;
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// Denied cross-tenant probes must still leave an audit trail (fw#2348).
|
|
109
|
+
// Appended outside the handler tx (fw#2592): the caller's
|
|
110
|
+
// `return tenantScopeDenial` rolls that tx back.
|
|
111
|
+
async function appendDenialAuditEvent(
|
|
112
|
+
ctx: HandlerContext,
|
|
113
|
+
event: WriteEvent<z.infer<typeof forgetSubjectSchema>>,
|
|
114
|
+
subjectKey: string,
|
|
115
|
+
subjectKind: SubjectIdInput["kind"],
|
|
116
|
+
denialCode: string,
|
|
117
|
+
): Promise<WriteFailure | null> {
|
|
118
|
+
const outsideTx = ctx.dbOutsideTransaction;
|
|
119
|
+
if (!outsideTx) {
|
|
120
|
+
return writeFailure(
|
|
121
|
+
new InternalError({
|
|
122
|
+
message:
|
|
123
|
+
"[crypto-shredding] forget-subject denial audit event cannot be appended without " +
|
|
124
|
+
"ctx.dbOutsideTransaction — dispatch wiring is missing the outside-tx db source.",
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
|
|
129
|
+
if (!eventDef) {
|
|
130
|
+
return writeFailure(
|
|
131
|
+
new InternalError({
|
|
132
|
+
message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const payload = subjectForgetDeniedSchema.parse({
|
|
137
|
+
// The denial lands in the REQUESTING actor's own tenant-scoped stream —
|
|
138
|
+
// it must never materialise the foreign subject's identifiers there. A
|
|
139
|
+
// plaintext subjectKey/aggregateId would survive as a permanent record
|
|
140
|
+
// for the prober and would still be present when the owning tenant
|
|
141
|
+
// later runs its own (legitimate) forget-subject for that subject. The
|
|
142
|
+
// digest still lets an operator correlate repeated probes of the same
|
|
143
|
+
// subject without exposing it (fw#2452).
|
|
144
|
+
subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
|
|
145
|
+
subjectKind,
|
|
146
|
+
reason: event.payload.reason,
|
|
147
|
+
forgottenBy: event.user.id,
|
|
148
|
+
actorTenantId: event.user.tenantId,
|
|
149
|
+
denial: denialCode,
|
|
150
|
+
});
|
|
151
|
+
const reqCtx = requestContext.get();
|
|
152
|
+
// This event is the only proof the denial happened. Skipping
|
|
153
|
+
// runProjectionsForEvent here is fine — nothing projects
|
|
154
|
+
// crypto-shredding:event:forget-denied.
|
|
155
|
+
await append(outsideTx.raw, {
|
|
156
|
+
aggregateId: generateId(),
|
|
157
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
158
|
+
// MUST be event.user.tenantId (the prober's own tenant), never
|
|
159
|
+
// SYSTEM_TENANT_ID — .raw bypasses TenantDb's scoping wrapper, so this
|
|
160
|
+
// is the only thing keeping the denial event out of the foreign
|
|
161
|
+
// subject's tenant (fw#2452).
|
|
162
|
+
tenantId: event.user.tenantId,
|
|
163
|
+
expectedVersion: 0,
|
|
164
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
165
|
+
eventVersion: eventDef.version,
|
|
166
|
+
payload,
|
|
167
|
+
metadata: {
|
|
168
|
+
userId: event.user.id,
|
|
169
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
170
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
171
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
102
177
|
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
103
178
|
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
104
179
|
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
@@ -138,20 +213,14 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
138
213
|
raw,
|
|
139
214
|
);
|
|
140
215
|
if (tenantScopeDenial) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
forgottenBy: event.user.id,
|
|
150
|
-
actorTenantId: event.user.tenantId,
|
|
151
|
-
denial: tenantScopeDenial.error.code,
|
|
152
|
-
},
|
|
153
|
-
});
|
|
154
|
-
return tenantScopeDenial;
|
|
216
|
+
const auditFailure = await appendDenialAuditEvent(
|
|
217
|
+
ctx,
|
|
218
|
+
event,
|
|
219
|
+
subjectKey,
|
|
220
|
+
raw.kind,
|
|
221
|
+
tenantScopeDenial.error.code,
|
|
222
|
+
);
|
|
223
|
+
return auditFailure ?? tenantScopeDenial;
|
|
155
224
|
}
|
|
156
225
|
|
|
157
226
|
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
@@ -451,10 +451,12 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
451
451
|
const RETRY_USER_ID = "u-pii-retry-1";
|
|
452
452
|
const RETRY_JOB_NAME = "retryapp:job:capture-import";
|
|
453
453
|
const capturedPayloads: Record<string, unknown>[] = [];
|
|
454
|
+
const capturedTriggeredBy: (string | null)[] = [];
|
|
454
455
|
|
|
455
456
|
const retryAppFeature = defineFeature("retryapp", (r) => {
|
|
456
|
-
r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
|
|
457
|
+
r.job("captureImport", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
457
458
|
capturedPayloads.push(payload);
|
|
459
|
+
capturedTriggeredBy.push(ctx.triggeredBy?.id ?? null);
|
|
458
460
|
});
|
|
459
461
|
});
|
|
460
462
|
|
|
@@ -476,6 +478,7 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
476
478
|
|
|
477
479
|
beforeEach(() => {
|
|
478
480
|
capturedPayloads.length = 0;
|
|
481
|
+
capturedTriggeredBy.length = 0;
|
|
479
482
|
retryKms = new InMemoryKmsAdapter();
|
|
480
483
|
configurePiiSubjectKms(retryKms);
|
|
481
484
|
});
|
|
@@ -527,6 +530,40 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
|
527
530
|
expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
|
|
528
531
|
});
|
|
529
532
|
|
|
533
|
+
// #2469: retry dispatched without a `meta` argument, so the retried run
|
|
534
|
+
// carried no triggeredById at all — job-run-logger's onJobStart then saw
|
|
535
|
+
// triggeredById: null, skipped encryption entirely, and the retried run's
|
|
536
|
+
// payload landed in job_runs as unshreddable plaintext. The asserted
|
|
537
|
+
// invariant is the input to that chain: the retried BullMQ job must carry
|
|
538
|
+
// the ORIGINAL run's owner, not the retrying SystemAdmin and not null.
|
|
539
|
+
// (Asserting the retried run's job_runs row directly is not possible here:
|
|
540
|
+
// this stack wires no job-run-logger into the runner, so a real dispatch
|
|
541
|
+
// writes no row — every other row in this file is seeded by calling
|
|
542
|
+
// retryLogger.onJobStart by hand.)
|
|
543
|
+
test("retry re-dispatches with the original run's triggeredById so the new run's payload stays encrypted", async () => {
|
|
544
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-5", {
|
|
545
|
+
triggeredById: RETRY_USER_ID,
|
|
546
|
+
payload: SECRET_PAYLOAD,
|
|
547
|
+
});
|
|
548
|
+
await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-5", "boom", []);
|
|
549
|
+
|
|
550
|
+
const originalRow = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-5" });
|
|
551
|
+
expect(isPiiCiphertext(originalRow?.["payload"])).toBe(true);
|
|
552
|
+
expect(originalRow?.["triggeredById"]).toBe(RETRY_USER_ID);
|
|
553
|
+
|
|
554
|
+
await retryStack.http.writeOk<{
|
|
555
|
+
jobName: string;
|
|
556
|
+
bullJobId: string;
|
|
557
|
+
retriedFromRunId: string;
|
|
558
|
+
}>(JobHandlers.retry, { runId: originalRow?.["id"] }, TestUsers.systemAdmin);
|
|
559
|
+
|
|
560
|
+
await sleep(1000);
|
|
561
|
+
|
|
562
|
+
expect(capturedTriggeredBy).toEqual([RETRY_USER_ID]);
|
|
563
|
+
expect(capturedTriggeredBy[0]).not.toBe(TestUsers.systemAdmin.id);
|
|
564
|
+
expect(capturedPayloads).toEqual([JSON.parse(SECRET_PAYLOAD)]);
|
|
565
|
+
});
|
|
566
|
+
|
|
530
567
|
// The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
|
|
531
568
|
// literal "[[erased]]" string (not ciphertext) when the key is already
|
|
532
569
|
// gone at onJobStart time (see the mirrored case above this describe
|
|
@@ -18,6 +18,7 @@ type JobRunRow = {
|
|
|
18
18
|
readonly status: string;
|
|
19
19
|
readonly jobName: string;
|
|
20
20
|
readonly payload: string | null;
|
|
21
|
+
readonly triggeredById: string | null;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
export const retryWrite = defineWriteHandler({
|
|
@@ -61,6 +62,7 @@ export const retryWrite = defineWriteHandler({
|
|
|
61
62
|
// PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
|
|
62
63
|
// dispatch it with a silently emptied payload.
|
|
63
64
|
let payload: Record<string, unknown> = {};
|
|
65
|
+
let decryptedPayloadJson: string | null = null;
|
|
64
66
|
if (run.payload) {
|
|
65
67
|
const decryptedPayload = await decryptStoredPii(
|
|
66
68
|
run.payload,
|
|
@@ -78,9 +80,14 @@ export const retryWrite = defineWriteHandler({
|
|
|
78
80
|
decryptedPayload,
|
|
79
81
|
`job run ${event.payload.runId} payload`,
|
|
80
82
|
);
|
|
83
|
+
decryptedPayloadJson = decryptedPayload;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
// Retry re-uses the original run's DEK owner so the retry's logs/payload stay encrypted.
|
|
87
|
+
const bullJobId = await jobRunner.dispatch(run.jobName, payload, {
|
|
88
|
+
triggeredById: run.triggeredById ?? undefined,
|
|
89
|
+
...(decryptedPayloadJson !== null && { payload: decryptedPayloadJson }),
|
|
90
|
+
});
|
|
84
91
|
|
|
85
92
|
return {
|
|
86
93
|
isSuccess: true,
|