@cosmicdrift/kumiko-bundled-features 0.87.2 → 0.88.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 +6 -6
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +43 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -1
- package/src/tenant/membership-roles.ts +2 -10
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
- package/src/user-data-rights/feature.ts +14 -0
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
- package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
- package/src/user-data-rights/screens.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.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>",
|
|
@@ -86,11 +86,11 @@
|
|
|
86
86
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
93
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
89
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.88.0",
|
|
90
|
+
"@cosmicdrift/kumiko-framework": "0.88.0",
|
|
91
|
+
"@cosmicdrift/kumiko-headless": "0.88.0",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer": "0.88.0",
|
|
93
|
+
"@cosmicdrift/kumiko-renderer-web": "0.88.0",
|
|
94
94
|
"@mollie/api-client": "^4.5.0",
|
|
95
95
|
"@node-rs/argon2": "^2.0.2",
|
|
96
96
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -251,3 +251,46 @@ describe("multi-roles: switch-tenant erhält globale rollen", () => {
|
|
|
251
251
|
expect(switchBody.roles).toEqual(["User"]);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
// Read-time backstop (engine/membership-roles): the write paths reject reserved
|
|
256
|
+
// roles at command time, but a projection rebuild replays stored membership
|
|
257
|
+
// events through the apply path, bypassing that check. addMembership inserts
|
|
258
|
+
// straight into the projection table — the same shape a rebuild would produce —
|
|
259
|
+
// so a forbidden role lands in the membership without going through a handler.
|
|
260
|
+
// Every JWT mint must strip it; globalRoles must survive untouched.
|
|
261
|
+
describe("multi-roles: reserved membership role stripped at the mint", () => {
|
|
262
|
+
test("login: resurrected ['SystemAdmin'] in membership → stripped, ['Admin'] survives", async () => {
|
|
263
|
+
const userId = await seedUser("resurrect@example.com", "pw-long-enough");
|
|
264
|
+
await addMembership(userId, tenantA, ["SystemAdmin", "Admin"]);
|
|
265
|
+
|
|
266
|
+
const { user } = await login("resurrect@example.com", "pw-long-enough");
|
|
267
|
+
expect(user.roles).toEqual(["Admin"]);
|
|
268
|
+
expect(user.roles).not.toContain("SystemAdmin");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("login: global SystemAdmin survives even when membership repeats it", async () => {
|
|
272
|
+
const userId = await seedUser("realadmin@example.com", "pw-long-enough", ["SystemAdmin"]);
|
|
273
|
+
await addMembership(userId, tenantA, ["SystemAdmin", "Admin"]);
|
|
274
|
+
|
|
275
|
+
const { user } = await login("realadmin@example.com", "pw-long-enough");
|
|
276
|
+
expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("switch-tenant: resurrected ['SystemAdmin'] on target tenant → stripped", async () => {
|
|
280
|
+
const userId = await seedUser("resurrect2@example.com", "pw-long-enough");
|
|
281
|
+
await addMembership(userId, tenantA, ["Admin"]);
|
|
282
|
+
await addMembership(userId, tenantB, ["SystemAdmin", "User"]);
|
|
283
|
+
|
|
284
|
+
const { token } = await login("resurrect2@example.com", "pw-long-enough");
|
|
285
|
+
const switchRes = await stack.http.raw(
|
|
286
|
+
"POST",
|
|
287
|
+
"/api/auth/switch-tenant",
|
|
288
|
+
{ tenantId: tenantB },
|
|
289
|
+
{ authorization: `Bearer ${token}` },
|
|
290
|
+
);
|
|
291
|
+
expect(switchRes.status).toBe(200);
|
|
292
|
+
const switchBody = (await switchRes.json()) as { roles: string[] };
|
|
293
|
+
expect(switchBody.roles).toEqual(["User"]);
|
|
294
|
+
expect(switchBody.roles).not.toContain("SystemAdmin");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
createSystemUser,
|
|
24
24
|
defineWriteHandler,
|
|
25
25
|
type SessionUser,
|
|
26
|
+
stripForbiddenMembershipRoles,
|
|
26
27
|
type TenantId,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
29
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -164,7 +165,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
164
165
|
const session: SessionUser = {
|
|
165
166
|
id: userId,
|
|
166
167
|
tenantId: invitationTenantId,
|
|
167
|
-
roles: [invitationRole],
|
|
168
|
+
roles: stripForbiddenMembershipRoles([invitationRole]),
|
|
168
169
|
};
|
|
169
170
|
|
|
170
171
|
committed = true;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
createSystemUser,
|
|
28
28
|
defineWriteHandler,
|
|
29
29
|
type SessionUser,
|
|
30
|
+
stripForbiddenMembershipRoles,
|
|
30
31
|
type TenantId,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
33
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -160,7 +161,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
160
161
|
const session: SessionUser = {
|
|
161
162
|
id: userId,
|
|
162
163
|
tenantId: invitationTenantId,
|
|
163
|
-
roles: [invitationRole],
|
|
164
|
+
roles: stripForbiddenMembershipRoles([invitationRole]),
|
|
164
165
|
};
|
|
165
166
|
|
|
166
167
|
committed = true;
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createSystemUser,
|
|
3
3
|
defineWriteHandler,
|
|
4
4
|
type SessionUser,
|
|
5
|
+
stripForbiddenMembershipRoles,
|
|
5
6
|
type TenantId,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -165,7 +166,11 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
165
166
|
// membership. Dedupe via Set damit eine Rolle die in beiden Quellen
|
|
166
167
|
// steht nicht doppelt im Session-Roles landet.
|
|
167
168
|
const globalRoles = parseRoles(found.roles ?? null);
|
|
168
|
-
|
|
169
|
+
// Strip reserved roles from the membership portion only (globalRoles keeps
|
|
170
|
+
// SystemAdmin) — read-time backstop against a rebuild-resurrected role.
|
|
171
|
+
const mergedRoles = Array.from(
|
|
172
|
+
new Set([...globalRoles, ...stripForbiddenMembershipRoles(chosen.roles)]),
|
|
173
|
+
);
|
|
169
174
|
const baseSession: SessionUser = {
|
|
170
175
|
id: found.id,
|
|
171
176
|
tenantId: chosen.tenantId,
|
|
@@ -6,18 +6,10 @@
|
|
|
6
6
|
// this validator makes every membership-role write path enforce the same
|
|
7
7
|
// invariant. Derived from the framework presets so it tracks access.privileged.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { findForbiddenMembershipRole } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
...access.privileged, // system, SystemAdmin
|
|
14
|
-
...access.all, // all
|
|
15
|
-
...access.anonymous, // anonymous
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
export function findForbiddenMembershipRole(roles: readonly string[]): string | undefined {
|
|
19
|
-
return roles.find((role) => FORBIDDEN_MEMBERSHIP_ROLES.has(role));
|
|
20
|
-
}
|
|
12
|
+
export { findForbiddenMembershipRole };
|
|
21
13
|
|
|
22
14
|
export function reservedMembershipRoleError(role: string): AccessDeniedError {
|
|
23
15
|
return new AccessDeniedError({
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
6
|
+
import { createSessionsFeature } from "../../sessions/feature";
|
|
7
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
9
|
+
|
|
10
|
+
// Read-only GDPR inspector screens live IN user-data-rights (the boot-validator
|
|
11
|
+
// forbids cross-feature screen ownership). The validator checks screen structure
|
|
12
|
+
// — entity-local binding, columns/fields exist on the entity, rowAction targets
|
|
13
|
+
// resolve — so a clean boot proves the entityList/entityEdit defs bind to the
|
|
14
|
+
// real event-sourced entities and the convention list/detail QNs exist. The
|
|
15
|
+
// entities (export-job, download-attempt) are r.entity, not direct-write stores,
|
|
16
|
+
// so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
|
|
17
|
+
|
|
18
|
+
describe("user-data-rights read-only inspector screens", () => {
|
|
19
|
+
const features = [
|
|
20
|
+
createConfigFeature(),
|
|
21
|
+
createUserFeature(),
|
|
22
|
+
createSessionsFeature(),
|
|
23
|
+
createDataRetentionFeature(),
|
|
24
|
+
createComplianceProfilesFeature(),
|
|
25
|
+
createUserDataRightsFeature(),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
test("the assembled feature set boot-validates", () => {
|
|
29
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ships SystemAdmin-gated list + detail screens", () => {
|
|
33
|
+
const f = createUserDataRightsFeature();
|
|
34
|
+
expect(Object.keys(f.screens)).toEqual(
|
|
35
|
+
expect.arrayContaining(["export-job-list", "export-job-detail", "download-attempt-list"]),
|
|
36
|
+
);
|
|
37
|
+
const list = f.screens["export-job-list"];
|
|
38
|
+
expect(list?.type).toBe("entityList");
|
|
39
|
+
expect(list?.access).toEqual({ roles: ["SystemAdmin"] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("export-job detail is strictly read-only (no create/delete, every field readOnly)", () => {
|
|
43
|
+
const f = createUserDataRightsFeature();
|
|
44
|
+
const edit = f.screens["export-job-detail"];
|
|
45
|
+
expect(edit?.type).toBe("entityEdit");
|
|
46
|
+
if (edit?.type === "entityEdit") {
|
|
47
|
+
expect(edit.allowCreate).toBe(false);
|
|
48
|
+
expect(edit.allowDelete).toBe(false);
|
|
49
|
+
const fields = edit.layout.sections.flatMap((s) => ("fields" in s ? s.fields : []));
|
|
50
|
+
expect(fields.length).toBeGreaterThan(0);
|
|
51
|
+
expect(fields.every((field) => typeof field === "object" && field.readOnly === true)).toBe(
|
|
52
|
+
true,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("convention list/detail handlers resolve the screen QNs", () => {
|
|
58
|
+
const f = createUserDataRightsFeature();
|
|
59
|
+
// entityList → user-data-rights:query:export-job:list; entityEdit detail →
|
|
60
|
+
// :export-job:detail; download-attempt list → :download-attempt:list.
|
|
61
|
+
expect(Object.keys(f.queryHandlers)).toEqual(
|
|
62
|
+
expect.arrayContaining(["export-job:list", "export-job:detail", "download-attempt:list"]),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
|
|
9
9
|
import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
|
|
10
10
|
import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
|
|
11
|
+
import { downloadAttemptListQuery } from "./handlers/download-attempt-list.query";
|
|
11
12
|
import { downloadByJobQuery } from "./handlers/download-by-job.query";
|
|
12
13
|
import { downloadByTokenQuery } from "./handlers/download-by-token.query";
|
|
14
|
+
import { exportJobDetailQuery } from "./handlers/export-job-detail.query";
|
|
15
|
+
import { exportJobListQuery } from "./handlers/export-job-list.query";
|
|
13
16
|
import { exportStatusQuery } from "./handlers/export-status.query";
|
|
14
17
|
import { liftRestrictionWrite } from "./handlers/lift-restriction.write";
|
|
15
18
|
import { listDownloadAttemptsQuery } from "./handlers/list-download-attempts.query";
|
|
@@ -36,6 +39,7 @@ import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "./run-forget
|
|
|
36
39
|
import { downloadAttemptEntity } from "./schema/download-attempt";
|
|
37
40
|
import { exportDownloadTokenEntity } from "./schema/download-token";
|
|
38
41
|
import { exportJobEntity } from "./schema/export-job";
|
|
42
|
+
import { downloadAttemptListScreen, exportJobDetailScreen, exportJobListScreen } from "./screens";
|
|
39
43
|
|
|
40
44
|
// user-data-rights — DSGVO Art. 15 (Auskunft) + Art. 17 (Löschung) +
|
|
41
45
|
// Art. 18 (Restriction) + Art. 20 (Portabilität) als Core-Feature.
|
|
@@ -222,6 +226,16 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
222
226
|
r.queryHandler(myAuditLogQuery);
|
|
223
227
|
r.queryHandler(listDownloadAttemptsQuery);
|
|
224
228
|
|
|
229
|
+
// Read-only operator inspector over the GDPR read-models (SystemAdmin).
|
|
230
|
+
// Convention list/detail handlers so entityList/entityEdit resolve by QN;
|
|
231
|
+
// the screens stay inert until an app navs them (opt-in at wire time).
|
|
232
|
+
r.queryHandler(exportJobListQuery);
|
|
233
|
+
r.queryHandler(exportJobDetailQuery);
|
|
234
|
+
r.queryHandler(downloadAttemptListQuery);
|
|
235
|
+
r.screen(exportJobListScreen);
|
|
236
|
+
r.screen(exportJobDetailScreen);
|
|
237
|
+
r.screen(downloadAttemptListScreen);
|
|
238
|
+
|
|
225
239
|
// Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
|
|
226
240
|
// protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
|
|
227
241
|
// App platziert ihn im eingeloggten Bereich. Die React-Component kommt
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { downloadAttemptEntity } from "../schema/download-attempt";
|
|
3
|
+
|
|
4
|
+
// SystemAdmin operator view of invalid download attempts (DPO brute-force
|
|
5
|
+
// triage). A bespoke list-download-attempts query already exists but sits on a
|
|
6
|
+
// non-convention QN; entityList needs the convention `download-attempt:list`.
|
|
7
|
+
export const downloadAttemptListQuery = defineEntityListHandler(
|
|
8
|
+
"download-attempt",
|
|
9
|
+
downloadAttemptEntity,
|
|
10
|
+
{ access: { roles: access.systemAdmin } },
|
|
11
|
+
);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { access, defineEntityDetailHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
3
|
+
|
|
4
|
+
// Detail fetch backing the read-only export-job inspector screen.
|
|
5
|
+
export const exportJobDetailQuery = defineEntityDetailHandler("export-job", exportJobEntity, {
|
|
6
|
+
access: { roles: access.systemAdmin },
|
|
7
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
3
|
+
|
|
4
|
+
// SystemAdmin operator view of GDPR Art. 20 export jobs. Read-only inspector —
|
|
5
|
+
// rows are created by the user's request-export flow, never through this handler.
|
|
6
|
+
export const exportJobListQuery = defineEntityListHandler("export-job", exportJobEntity, {
|
|
7
|
+
access: { roles: access.systemAdmin },
|
|
8
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityEditScreenDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
|
|
6
|
+
// Read-only operator inspector for the GDPR data-rights read-models. All
|
|
7
|
+
// screens are SystemAdmin-gated and inert until an app navs them — the feature
|
|
8
|
+
// only registers them, the app opts in per screen via r.nav (see the
|
|
9
|
+
// mount-inspector-screens guide). The entities are event-sourced r.entity rows,
|
|
10
|
+
// so binding entityList/entityEdit to them is safe (no direct-write rebuild
|
|
11
|
+
// hazard like jobs/sessions).
|
|
12
|
+
|
|
13
|
+
// Read-only field shorthand for the detail screens — every field is display-only.
|
|
14
|
+
const ro = (field: string) => ({ field, readOnly: true });
|
|
15
|
+
|
|
16
|
+
export const exportJobListScreen: EntityListScreenDefinition = {
|
|
17
|
+
id: "export-job-list",
|
|
18
|
+
type: "entityList",
|
|
19
|
+
entity: "export-job",
|
|
20
|
+
columns: ["userId", "status", "requestedAt", "completedAt", "expiresAt"],
|
|
21
|
+
rowActions: [
|
|
22
|
+
{
|
|
23
|
+
kind: "navigate",
|
|
24
|
+
id: "view",
|
|
25
|
+
label: "kumiko.actions.view",
|
|
26
|
+
screen: "export-job-detail",
|
|
27
|
+
entityId: "id",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
searchable: false,
|
|
31
|
+
access: { roles: ["SystemAdmin"] },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const exportJobDetailScreen: EntityEditScreenDefinition = {
|
|
35
|
+
id: "export-job-detail",
|
|
36
|
+
type: "entityEdit",
|
|
37
|
+
entity: "export-job",
|
|
38
|
+
layout: {
|
|
39
|
+
sections: [
|
|
40
|
+
{
|
|
41
|
+
columns: 2,
|
|
42
|
+
fields: [
|
|
43
|
+
ro("userId"),
|
|
44
|
+
ro("requestedFromTenantId"),
|
|
45
|
+
ro("status"),
|
|
46
|
+
ro("requestedAt"),
|
|
47
|
+
ro("startedAt"),
|
|
48
|
+
ro("completedAt"),
|
|
49
|
+
ro("expiresAt"),
|
|
50
|
+
ro("downloadStorageKey"),
|
|
51
|
+
ro("bytesWritten"),
|
|
52
|
+
ro("errorMessage"),
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
// Inspector is strictly read-only: no export-job:create/update/delete handler
|
|
58
|
+
// exists, and the export lifecycle is driven by the worker, not an operator.
|
|
59
|
+
allowCreate: false,
|
|
60
|
+
allowDelete: false,
|
|
61
|
+
access: { roles: ["SystemAdmin"] },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const downloadAttemptListScreen: EntityListScreenDefinition = {
|
|
65
|
+
id: "download-attempt-list",
|
|
66
|
+
type: "entityList",
|
|
67
|
+
entity: "download-attempt",
|
|
68
|
+
columns: ["attemptedAt", "result", "via", "ip", "attemptedByUserId", "jobId"],
|
|
69
|
+
searchable: false,
|
|
70
|
+
access: { roles: ["SystemAdmin"] },
|
|
71
|
+
};
|