@cosmicdrift/kumiko-bundled-features 0.87.0 → 0.87.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.87.0",
3
+ "version": "0.87.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>",
@@ -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.0",
90
- "@cosmicdrift/kumiko-framework": "0.87.0",
91
- "@cosmicdrift/kumiko-headless": "0.87.0",
92
- "@cosmicdrift/kumiko-renderer": "0.87.0",
93
- "@cosmicdrift/kumiko-renderer-web": "0.87.0",
89
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.2",
90
+ "@cosmicdrift/kumiko-framework": "0.87.2",
91
+ "@cosmicdrift/kumiko-headless": "0.87.2",
92
+ "@cosmicdrift/kumiko-renderer": "0.87.2",
93
+ "@cosmicdrift/kumiko-renderer-web": "0.87.2",
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
+ });
@@ -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 });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { emailPasswordClient } from "../client-plugin";
3
+ import { hasLikelyAuthSession } from "../session";
4
+
5
+ describe("hasLikelyAuthSession", () => {
6
+ test("no kumiko_csrf cookie → false", () => {
7
+ expect(hasLikelyAuthSession("theme=dark")).toBe(false);
8
+ });
9
+
10
+ test("kumiko_csrf present → true", () => {
11
+ expect(hasLikelyAuthSession("kumiko_csrf=abc-123")).toBe(true);
12
+ });
13
+ });
14
+
15
+ describe("emailPasswordClient", () => {
16
+ test("registers SessionAuthGate as gate, not SessionProvider as provider", () => {
17
+ const feature = emailPasswordClient();
18
+ expect(feature.providers).toEqual([]);
19
+ expect(feature.gates).toHaveLength(1);
20
+ expect(feature.gates[0]?.name).toBe("SessionAuthGate");
21
+ });
22
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { ComponentType, ReactNode } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
- import { useSession } from "./session";
14
+ import { SessionProvider, useSession } from "./session";
15
15
 
16
16
  export function makeAuthGate(
17
17
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
@@ -31,3 +31,22 @@ export function makeAuthGate(
31
31
  }
32
32
  return AuthGate;
33
33
  }
34
+
35
+ /** SessionProvider + AuthGate als ein Gate — damit öffentliche Gates davor
36
+ * (z.B. /rechner) den Session-Bootstrap nicht mounten. createKumikoApp
37
+ * stackt providers außerhalb aller gates; SessionProvider darf deshalb
38
+ * kein provider mehr sein. */
39
+ export function makeSessionAuthGate(
40
+ LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
41
+ loginProps?: LoginScreenProps,
42
+ ): ComponentType<{ children: ReactNode }> {
43
+ const AuthGate = makeAuthGate(LoginComponent, loginProps);
44
+ function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
45
+ return (
46
+ <SessionProvider>
47
+ <AuthGate>{children}</AuthGate>
48
+ </SessionProvider>
49
+ );
50
+ }
51
+ return SessionAuthGate;
52
+ }
@@ -8,9 +8,8 @@
8
8
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ComponentType, ReactNode } from "react";
10
10
  import { defaultTranslations, mergeTranslations } from "../i18n";
11
- import { makeAuthGate } from "./auth-gate";
11
+ import { makeSessionAuthGate } from "./auth-gate";
12
12
  import type { LoginScreenProps } from "./login-screen";
13
- import { SessionProvider } from "./session";
14
13
 
15
14
  export type EmailPasswordClientOptions = {
16
15
  /** Eigener Login-Screen. Default: der shadcn-stylte LoginScreen
@@ -41,8 +40,8 @@ export function emailPasswordClient(
41
40
  const translations = mergeTranslations(defaultTranslations, options.translations ?? {});
42
41
  return {
43
42
  name: "auth-email-password",
44
- providers: [SessionProvider],
45
- gates: [makeAuthGate(options.loginScreen, options.loginScreenProps)],
43
+ providers: [],
44
+ gates: [makeSessionAuthGate(options.loginScreen, options.loginScreenProps)],
46
45
  translations,
47
46
  };
48
47
  }
@@ -26,7 +26,7 @@ export {
26
26
  } from "./auth-client";
27
27
  export type { AuthShellRenderer } from "./auth-form-primitives";
28
28
  export { AuthShellProvider, useAuthShell } from "./auth-form-primitives";
29
- export { makeAuthGate } from "./auth-gate";
29
+ export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
30
30
  export type {
31
31
  EmailPasswordClientFeature,
32
32
  EmailPasswordClientOptions,
@@ -43,7 +43,7 @@ export { LoginScreen } from "./login-screen";
43
43
  export type { ResetPasswordScreenProps } from "./reset-password-screen";
44
44
  export { ResetPasswordScreen } from "./reset-password-screen";
45
45
  export type { SessionApi, SessionState, SessionStatus } from "./session";
46
- export { SessionContext, SessionProvider, useSession } from "./session";
46
+ export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session";
47
47
  export type { SignupCompleteScreenProps } from "./signup-complete-screen";
48
48
  export { SignupCompleteScreen } from "./signup-complete-screen";
49
49
  export type { SignupScreenProps } from "./signup-screen";
@@ -9,6 +9,7 @@
9
9
  // unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
10
10
  // und die Transitions.
11
11
 
12
+ import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
12
13
  import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
13
14
  import {
14
15
  type CurrentUserProfile,
@@ -43,6 +44,14 @@ export type SessionApi = SessionState & {
43
44
  readonly switchTenant: (tenantId: string) => Promise<void>;
44
45
  };
45
46
 
47
+ const UNAUTHENTICATED: SessionState = {
48
+ status: "unauthenticated",
49
+ user: null,
50
+ activeTenantId: null,
51
+ tenants: [],
52
+ roles: [],
53
+ };
54
+
46
55
  const INITIAL: SessionState = {
47
56
  status: "loading",
48
57
  user: null,
@@ -51,6 +60,11 @@ const INITIAL: SessionState = {
51
60
  roles: [],
52
61
  };
53
62
 
63
+ // kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
64
+ export function hasLikelyAuthSession(cookieSource?: string): boolean {
65
+ return readCsrfToken(cookieSource) !== undefined;
66
+ }
67
+
54
68
  // Exported damit tests den merge-pfad direkt pinnen können — der hier
55
69
  // muss byte-identisch zum server-side merge in auth-routes.ts +
56
70
  // login.write.ts sein, sonst sieht der Client andere session-rollen
@@ -76,27 +90,18 @@ export function computeActiveRoles(
76
90
  export const SessionContext = createContext<SessionApi | undefined>(undefined);
77
91
 
78
92
  // Eine Refresh-Runde: /auth/tenants → wenn 401 nicht-eingeloggt, sonst
79
- // parallel /user:me. Beides zusammen ergibt den vollen SessionState.
93
+ // /user:me. Beides zusammen ergibt den vollen SessionState.
80
94
  async function refresh(): Promise<SessionState> {
95
+ if (!hasLikelyAuthSession()) {
96
+ return UNAUTHENTICATED;
97
+ }
81
98
  const tenants = await fetchTenants();
82
99
  if (tenants === null) {
83
- return {
84
- status: "unauthenticated",
85
- user: null,
86
- activeTenantId: null,
87
- tenants: [],
88
- roles: [],
89
- };
100
+ return UNAUTHENTICATED;
90
101
  }
91
102
  const user = await fetchCurrentUser();
92
103
  if (user === null) {
93
- return {
94
- status: "unauthenticated",
95
- user: null,
96
- activeTenantId: null,
97
- tenants: [],
98
- roles: [],
99
- };
104
+ return UNAUTHENTICATED;
100
105
  }
101
106
  return {
102
107
  status: "authenticated",
@@ -131,13 +136,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
131
136
 
132
137
  const logout = useCallback<SessionApi["logout"]>(async () => {
133
138
  await logoutApi();
134
- setState({
135
- status: "unauthenticated",
136
- user: null,
137
- activeTenantId: null,
138
- tenants: [],
139
- roles: [],
140
- });
139
+ setState(UNAUTHENTICATED);
141
140
  // Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
142
141
  // alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
143
142
  // sauberer Ausgangslage, ohne dass wir jeden einzelnen Consumer
@@ -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
- AccessDeniedError,
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
- if (tenantOverride !== undefined && !event.user.roles.includes(ROLES.SystemAdmin)) {
62
- return writeFailure(
63
- new AccessDeniedError({
64
- i18nKey: "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
65
- details: { reason: "tenant_override_requires_system_admin" },
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 { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
- import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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
- if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
46
- return writeFailure(
47
- new AccessDeniedError({
48
- i18nKey: "managedPages.errors.tenantOverrideRequiresSystemAdmin",
49
- details: { reason: "tenant_override_requires_system_admin" },
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
- if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
29
- return writeFailure(
30
- new AccessDeniedError({
31
- i18nKey: "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
32
- details: { reason: "tenant_override_requires_system_admin" },
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,32 @@
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 { access } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
11
+
12
+ const FORBIDDEN_MEMBERSHIP_ROLES: ReadonlySet<string> = new Set<string>([
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
+ }
21
+
22
+ export function reservedMembershipRoleError(role: string): AccessDeniedError {
23
+ return new AccessDeniedError({
24
+ message: `role "${role}" is reserved and cannot be assigned to a tenant membership`,
25
+ details: { reason: "reserved_membership_role", role },
26
+ });
27
+ }
28
+
29
+ export function assertAssignableMembershipRoles(roles: readonly string[]): void {
30
+ const forbidden = findForbiddenMembershipRole(roles);
31
+ if (forbidden !== undefined) throw reservedMembershipRoleError(forbidden);
32
+ }
@@ -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 { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
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
- if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
31
- throw new AccessDeniedError({
32
- i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
33
- details: { reason: "tenant_override_requires_system_admin" },
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 { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
4
- import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
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
- if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
38
- throw new AccessDeniedError({
39
- i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
40
- details: { reason: "tenant_override_requires_system_admin" },
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 { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
- import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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
- if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
72
- return writeFailure(
73
- new AccessDeniedError({
74
- i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
75
- details: { reason: "tenant_override_requires_system_admin" },
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