@cosmicdrift/kumiko-bundled-features 0.87.1 → 0.87.3
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__/invite-flow.integration.test.ts +35 -0
- 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-create.write.ts +10 -0
- 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/compliance-profiles/handlers/set-profile.write.ts +11 -10
- package/src/managed-pages/handlers/set.write.ts +12 -10
- package/src/template-resolver/handlers/upsert-tenant.write.ts +7 -8
- package/src/tenant/__tests__/tenant.integration.test.ts +31 -0
- package/src/tenant/handlers/add-member.write.ts +3 -0
- package/src/tenant/handlers/update-member-roles.write.ts +3 -0
- package/src/tenant/membership-roles.test.ts +29 -0
- package/src/tenant/membership-roles.ts +24 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/text-content/handlers/by-slug.query.ts +10 -8
- package/src/text-content/handlers/by-tenant.query.ts +10 -8
- package/src/text-content/handlers/set.write.ts +12 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.87.
|
|
3
|
+
"version": "0.87.3",
|
|
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.87.
|
|
90
|
-
"@cosmicdrift/kumiko-framework": "0.87.
|
|
91
|
-
"@cosmicdrift/kumiko-headless": "0.87.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer": "0.87.
|
|
93
|
-
"@cosmicdrift/kumiko-renderer-web": "0.87.
|
|
89
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.87.3",
|
|
90
|
+
"@cosmicdrift/kumiko-framework": "0.87.3",
|
|
91
|
+
"@cosmicdrift/kumiko-headless": "0.87.3",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer": "0.87.3",
|
|
93
|
+
"@cosmicdrift/kumiko-renderer-web": "0.87.3",
|
|
94
94
|
"@mollie/api-client": "^4.5.0",
|
|
95
95
|
"@node-rs/argon2": "^2.0.2",
|
|
96
96
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -429,3 +429,38 @@ describe("invitations-query (pending list)", () => {
|
|
|
429
429
|
expect(list[0]?.status).toBe("pending");
|
|
430
430
|
});
|
|
431
431
|
});
|
|
432
|
+
|
|
433
|
+
// Privilege-escalation regression: a Tenant-Admin must not be able to seed a
|
|
434
|
+
// platform-global/reserved role (SystemAdmin, system, all, anonymous) into a
|
|
435
|
+
// tenant membership via the invite flow — once it lands in membership.roles it
|
|
436
|
+
// merges flat into the session and unlocks the SystemAdmin-gated cross-tenant
|
|
437
|
+
// handler surface (hasAccess can't tell membership roles from global ones).
|
|
438
|
+
describe("privilege escalation via invite role", () => {
|
|
439
|
+
// Each forbidden value is a platform-global/reserved role that must never
|
|
440
|
+
// reach a tenant membership. Proven exploitable before the fix: inviting
|
|
441
|
+
// "SystemAdmin" gave the invitee a JWT carrying SystemAdmin flat, which
|
|
442
|
+
// passed every SystemAdmin gate cross-tenant.
|
|
443
|
+
const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"];
|
|
444
|
+
|
|
445
|
+
test("invite-create rejects reserved/global roles — no invitation persisted", async () => {
|
|
446
|
+
for (const role of FORBIDDEN_ROLES) {
|
|
447
|
+
const err = await stack.http.writeErr(
|
|
448
|
+
AuthHandlers.inviteCreate,
|
|
449
|
+
{ email: CAROL_EMAIL, role },
|
|
450
|
+
aliceSession(),
|
|
451
|
+
);
|
|
452
|
+
expect(err.code).toBe("access_denied");
|
|
453
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: CAROL_EMAIL });
|
|
454
|
+
expect(rows).toHaveLength(0);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("legitimate tenant role still issues an invitation", async () => {
|
|
459
|
+
const result = (await stack.http.writeOk(
|
|
460
|
+
AuthHandlers.inviteCreate,
|
|
461
|
+
{ email: CAROL_EMAIL, role: "Editor" },
|
|
462
|
+
aliceSession(),
|
|
463
|
+
)) as { role: string };
|
|
464
|
+
expect(result.role).toBe("Editor");
|
|
465
|
+
});
|
|
466
|
+
});
|
|
@@ -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,11 @@ import {
|
|
|
27
27
|
tenantInvitationEntity,
|
|
28
28
|
tenantInvitationsTable,
|
|
29
29
|
} from "../../tenant/invitation-table";
|
|
30
|
+
// kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
|
|
31
|
+
import {
|
|
32
|
+
findForbiddenMembershipRole,
|
|
33
|
+
reservedMembershipRoleError,
|
|
34
|
+
} from "../../tenant/membership-roles";
|
|
30
35
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
|
|
31
36
|
import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
|
|
32
37
|
|
|
@@ -69,6 +74,11 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
|
|
|
69
74
|
);
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
const forbiddenRole = findForbiddenMembershipRole([event.payload.role]);
|
|
78
|
+
if (forbiddenRole !== undefined) {
|
|
79
|
+
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
const email = event.payload.email.toLowerCase();
|
|
73
83
|
const tenantId = event.user.tenantId;
|
|
74
84
|
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
@@ -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,
|
|
@@ -5,9 +5,12 @@ import {
|
|
|
5
5
|
SELECTABLE_PROFILE_KEYS,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
7
7
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
-
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
8
|
import {
|
|
10
|
-
|
|
9
|
+
crossTenantOverrideDenied,
|
|
10
|
+
defineWriteHandler,
|
|
11
|
+
type TenantId,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import {
|
|
11
14
|
UnprocessableError,
|
|
12
15
|
validationErrorFromZod,
|
|
13
16
|
writeFailure,
|
|
@@ -58,14 +61,12 @@ export const setProfileWrite = defineWriteHandler({
|
|
|
58
61
|
access: { roles: [ROLES.TenantAdmin, ROLES.SystemAdmin] },
|
|
59
62
|
handler: async (event, ctx) => {
|
|
60
63
|
const tenantOverride = event.payload.tenantIdOverride;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
68
|
-
}
|
|
64
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
65
|
+
event.user,
|
|
66
|
+
tenantOverride,
|
|
67
|
+
"complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
|
|
68
|
+
);
|
|
69
|
+
if (overrideDenied) return writeFailure(overrideDenied);
|
|
69
70
|
const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
|
|
70
71
|
const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
|
|
71
72
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
crossTenantOverrideDenied,
|
|
5
|
+
defineWriteHandler,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { type PageRow, pageEntity, pagesTable } from "../table";
|
|
7
11
|
|
|
@@ -42,14 +46,12 @@ export const setWrite = defineWriteHandler({
|
|
|
42
46
|
handler: async (event, ctx) => {
|
|
43
47
|
const db = ctx.db;
|
|
44
48
|
const override = event.payload.tenantIdOverride;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
);
|
|
52
|
-
}
|
|
49
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
50
|
+
event.user,
|
|
51
|
+
override,
|
|
52
|
+
"managedPages.errors.tenantOverrideRequiresSystemAdmin",
|
|
53
|
+
);
|
|
54
|
+
if (overrideDenied) return writeFailure(overrideDenied);
|
|
53
55
|
const tenantId = override ?? event.user.tenantId;
|
|
54
56
|
// override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
|
|
55
57
|
const executorUser =
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
|
+
crossTenantOverrideDenied,
|
|
3
4
|
defineWriteHandler,
|
|
4
5
|
SYSTEM_TENANT_ID,
|
|
5
6
|
type TenantId,
|
|
@@ -25,14 +26,12 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
25
26
|
handler: async (event, ctx) => {
|
|
26
27
|
const db = ctx.db;
|
|
27
28
|
const override = event.payload.tenantIdOverride;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
35
|
-
}
|
|
29
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
30
|
+
event.user,
|
|
31
|
+
override,
|
|
32
|
+
"templateResolver.errors.tenantOverrideRequiresSystemAdmin",
|
|
33
|
+
);
|
|
34
|
+
if (overrideDenied) return writeFailure(overrideDenied);
|
|
36
35
|
// upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
|
|
37
36
|
// scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
|
|
38
37
|
// (Resolver-Logik trennt sauber zwischen system+tenant). SystemAdmin muss
|
|
@@ -433,3 +433,34 @@ describe("scenario 8: entityList/entityEdit convention QNs", () => {
|
|
|
433
433
|
]);
|
|
434
434
|
});
|
|
435
435
|
});
|
|
436
|
+
|
|
437
|
+
// Privilege-escalation guard: platform-global/reserved roles must never be
|
|
438
|
+
// writable into a tenant membership, not even by a SystemAdmin — once present
|
|
439
|
+
// they merge flat into the session and grant cross-tenant authority. The
|
|
440
|
+
// forbidden-role check runs before any DB access, so these reject without the
|
|
441
|
+
// memberships table being mounted in this stack.
|
|
442
|
+
describe("scenario 9: membership role escalation guard", () => {
|
|
443
|
+
const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"];
|
|
444
|
+
|
|
445
|
+
test("add-member rejects reserved/global roles even for SystemAdmin", async () => {
|
|
446
|
+
for (const role of FORBIDDEN_ROLES) {
|
|
447
|
+
const err = await stack.http.writeErr(
|
|
448
|
+
TenantHandlers.addMember,
|
|
449
|
+
{ userId: systemAdmin.id, tenantId: systemAdmin.tenantId, roles: [role] },
|
|
450
|
+
systemAdmin,
|
|
451
|
+
);
|
|
452
|
+
expectErrorIncludes(err, "access_denied");
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("update-member-roles rejects reserved/global roles even for SystemAdmin", async () => {
|
|
457
|
+
for (const role of FORBIDDEN_ROLES) {
|
|
458
|
+
const err = await stack.http.writeErr(
|
|
459
|
+
TenantHandlers.updateMemberRoles,
|
|
460
|
+
{ userId: systemAdmin.id, tenantId: systemAdmin.tenantId, roles: [role] },
|
|
461
|
+
systemAdmin,
|
|
462
|
+
);
|
|
463
|
+
expectErrorIncludes(err, "access_denied");
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
@@ -4,6 +4,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
4
4
|
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { TenantErrors } from "../constants";
|
|
7
|
+
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles";
|
|
7
8
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
8
9
|
|
|
9
10
|
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
@@ -20,6 +21,8 @@ export const addMemberWrite = defineWriteHandler({
|
|
|
20
21
|
access: { roles: ["SystemAdmin"] },
|
|
21
22
|
handler: async (event, ctx) => {
|
|
22
23
|
const db = ctx.db;
|
|
24
|
+
const forbidden = findForbiddenMembershipRole(event.payload.roles);
|
|
25
|
+
if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
|
|
23
26
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
24
27
|
userId: event.payload.userId,
|
|
25
28
|
tenantId: event.payload.tenantId,
|
|
@@ -3,6 +3,7 @@ import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framew
|
|
|
3
3
|
import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles";
|
|
6
7
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
7
8
|
|
|
8
9
|
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
@@ -23,6 +24,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
23
24
|
access: { roles: ["system", "SystemAdmin"] },
|
|
24
25
|
handler: async (event, ctx) => {
|
|
25
26
|
const db = ctx.db;
|
|
27
|
+
const forbidden = findForbiddenMembershipRole(event.payload.roles);
|
|
28
|
+
if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
|
|
26
29
|
const existing = await fetchOne(db, tenantMembershipsTable, {
|
|
27
30
|
userId: event.payload.userId,
|
|
28
31
|
tenantId: event.payload.tenantId,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { assertAssignableMembershipRoles, findForbiddenMembershipRole } from "./membership-roles";
|
|
4
|
+
|
|
5
|
+
describe("membership-roles", () => {
|
|
6
|
+
const FORBIDDEN = ["system", "SystemAdmin", "all", "anonymous"];
|
|
7
|
+
|
|
8
|
+
test("findForbiddenMembershipRole flags each reserved/global role", () => {
|
|
9
|
+
for (const role of FORBIDDEN) {
|
|
10
|
+
expect(findForbiddenMembershipRole([role])).toBe(role);
|
|
11
|
+
expect(findForbiddenMembershipRole(["Admin", role, "User"])).toBe(role);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("findForbiddenMembershipRole allows legitimate tenant roles", () => {
|
|
16
|
+
expect(findForbiddenMembershipRole(["Admin", "Editor", "User", "TenantAdmin"])).toBeUndefined();
|
|
17
|
+
expect(findForbiddenMembershipRole([])).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("assertAssignableMembershipRoles throws AccessDeniedError on a forbidden role", () => {
|
|
21
|
+
expect(() => assertAssignableMembershipRoles(["Admin", "SystemAdmin"])).toThrow(
|
|
22
|
+
AccessDeniedError,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("assertAssignableMembershipRoles passes legitimate roles", () => {
|
|
27
|
+
expect(() => assertAssignableMembershipRoles(["Admin", "User"])).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A tenant membership must never carry a platform-global/reserved role.
|
|
2
|
+
// hasAccess checks session.roles flat, with no notion of where a role came
|
|
3
|
+
// from — so a membership role like "SystemAdmin" merges into the session at
|
|
4
|
+
// login/switch and unlocks the SystemAdmin-gated, cross-tenant handler
|
|
5
|
+
// surface. The seed path already keeps these roles global-only (users.roles);
|
|
6
|
+
// this validator makes every membership-role write path enforce the same
|
|
7
|
+
// invariant. Derived from the framework presets so it tracks access.privileged.
|
|
8
|
+
|
|
9
|
+
import { findForbiddenMembershipRole } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
|
+
|
|
12
|
+
export { findForbiddenMembershipRole };
|
|
13
|
+
|
|
14
|
+
export function reservedMembershipRoleError(role: string): AccessDeniedError {
|
|
15
|
+
return new AccessDeniedError({
|
|
16
|
+
message: `role "${role}" is reserved and cannot be assigned to a tenant membership`,
|
|
17
|
+
details: { reason: "reserved_membership_role", role },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertAssignableMembershipRoles(roles: readonly string[]): void {
|
|
22
|
+
const forbidden = findForbiddenMembershipRole(roles);
|
|
23
|
+
if (forbidden !== undefined) throw reservedMembershipRoleError(forbidden);
|
|
24
|
+
}
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
38
38
|
import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
39
39
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
40
|
+
import { assertAssignableMembershipRoles } from "./membership-roles";
|
|
40
41
|
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
41
42
|
import { tenantEntity, tenantTable } from "./schema/tenant";
|
|
42
43
|
|
|
@@ -127,6 +128,9 @@ export async function seedTenantMembership(
|
|
|
127
128
|
db: DbRunner,
|
|
128
129
|
options: SeedTenantMembershipOptions,
|
|
129
130
|
): Promise<{ id: string }> {
|
|
131
|
+
// Chokepoint: invite-accept (×3) + seedAdmin/provisionSignup all flow
|
|
132
|
+
// through here — reject reserved/global roles before they ever persist.
|
|
133
|
+
assertAssignableMembershipRoles(options.roles);
|
|
130
134
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
131
135
|
// Wrap into a system-scoped TenantDb so the insert respects the tenant-
|
|
132
136
|
// override (we write into options.tenantId, which may differ from by.tenantId).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
crossTenantOverrideDenied,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
4
6
|
import { z } from "zod";
|
|
5
7
|
import { type TextBlockRow, textBlocksTable } from "../table";
|
|
6
8
|
|
|
@@ -27,12 +29,12 @@ export const bySlugQuery = defineQueryHandler({
|
|
|
27
29
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
28
30
|
handler: async (query, ctx) => {
|
|
29
31
|
const override = query.payload.tenantIdOverride;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
33
|
+
query.user,
|
|
34
|
+
override,
|
|
35
|
+
"textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
36
|
+
);
|
|
37
|
+
if (overrideDenied) throw overrideDenied;
|
|
36
38
|
const tenantId = override ?? query.user.tenantId;
|
|
37
39
|
const row = await fetchOne<TextBlockRow>(ctx.db, textBlocksTable, {
|
|
38
40
|
tenantId,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
crossTenantOverrideDenied,
|
|
5
|
+
defineQueryHandler,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
7
|
import { z } from "zod";
|
|
6
8
|
import { type TextBlockRow, textBlocksTable } from "../table";
|
|
7
9
|
|
|
@@ -34,12 +36,12 @@ export const byTenantQuery = defineQueryHandler({
|
|
|
34
36
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
35
37
|
handler: async (query, ctx) => {
|
|
36
38
|
const override = query.payload.tenantIdOverride;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
40
|
+
query.user,
|
|
41
|
+
override,
|
|
42
|
+
"textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
43
|
+
);
|
|
44
|
+
if (overrideDenied) throw overrideDenied;
|
|
43
45
|
const tenantId = override ?? query.user.tenantId;
|
|
44
46
|
const rows = castTenantRows<TextBlockRow>(
|
|
45
47
|
await selectMany(ctx.db, textBlocksTable, { tenantId: tenantId }),
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
crossTenantOverrideDenied,
|
|
5
|
+
defineWriteHandler,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
|
|
7
11
|
|
|
@@ -68,14 +72,12 @@ export const setWrite = defineWriteHandler({
|
|
|
68
72
|
handler: async (event, ctx) => {
|
|
69
73
|
const db = ctx.db;
|
|
70
74
|
const override = event.payload.tenantIdOverride;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
}
|
|
75
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
76
|
+
event.user,
|
|
77
|
+
override,
|
|
78
|
+
"textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
79
|
+
);
|
|
80
|
+
if (overrideDenied) return writeFailure(overrideDenied);
|
|
79
81
|
const tenantId = override ?? event.user.tenantId;
|
|
80
82
|
// Bei tenantIdOverride muss auch der user-context auf den ziel-tenant
|
|
81
83
|
// umgestellt werden, sonst läuft der event-store-Lookup
|