@cosmicdrift/kumiko-bundled-features 0.235.0 → 0.235.2
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/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/crypto-shredding/__tests__/forget-subject.integration.test.ts +90 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +81 -22
- package/src/workflow-runner/event-trigger.ts +4 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.235.
|
|
3
|
+
"version": "0.235.2",
|
|
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.235.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.235.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.235.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.235.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.235.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.235.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.2",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.235.2",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.235.2",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.235.2",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.235.2",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.235.2",
|
|
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.235.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.235.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.2",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.235.2"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -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" },
|
|
@@ -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",
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
defineWriteHandler,
|
|
16
16
|
type FeatureDefinition,
|
|
17
|
+
type HandlerContext,
|
|
17
18
|
type SessionUser,
|
|
18
19
|
type TenantId,
|
|
20
|
+
type WriteEvent,
|
|
19
21
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
22
|
import {
|
|
21
23
|
AccessDeniedError,
|
|
@@ -23,7 +25,9 @@ import {
|
|
|
23
25
|
type WriteFailure,
|
|
24
26
|
writeFailure,
|
|
25
27
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
29
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
30
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
27
31
|
import { z } from "zod";
|
|
28
32
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
|
|
29
33
|
import { USER_STATUS } from "../../user";
|
|
@@ -101,6 +105,75 @@ async function resolveTenantScopeDenial(
|
|
|
101
105
|
return ownedInTenant ? undefined : memberDenied;
|
|
102
106
|
}
|
|
103
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
|
+
|
|
104
177
|
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
105
178
|
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
106
179
|
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
@@ -140,28 +213,14 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
140
213
|
raw,
|
|
141
214
|
);
|
|
142
215
|
if (tenantScopeDenial) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await ctx.unsafeAppendEvent({
|
|
152
|
-
aggregateId: event.user.id,
|
|
153
|
-
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
154
|
-
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
155
|
-
payload: {
|
|
156
|
-
subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
|
|
157
|
-
subjectKind: raw.kind,
|
|
158
|
-
reason: event.payload.reason,
|
|
159
|
-
forgottenBy: event.user.id,
|
|
160
|
-
actorTenantId: event.user.tenantId,
|
|
161
|
-
denial: tenantScopeDenial.error.code,
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
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;
|
|
165
224
|
}
|
|
166
225
|
|
|
167
226
|
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
// Registration pattern:
|
|
5
5
|
// registerEventTrigger(r, myWorkflow)
|
|
6
6
|
//
|
|
7
|
-
// The
|
|
8
|
-
//
|
|
9
|
-
// from startAndRunWorkflow is recorded
|
|
10
|
-
//
|
|
11
|
-
// past the trigger event instead of redelivering it.
|
|
7
|
+
// The apply-fn does NOT run in the dispatcher's cursor tx (the server's MSP
|
|
8
|
+
// wiring uses the unbound pool), so `workflow.run-started` and the rest of
|
|
9
|
+
// the pipeline are not atomic. A throw from startAndRunWorkflow is recorded
|
|
10
|
+
// as `workflow.run-failed` and swallowed, so the dispatcher moves on.
|
|
12
11
|
|
|
13
12
|
import type {
|
|
14
13
|
FeatureRegistrar,
|