@cosmicdrift/kumiko-framework 0.215.7 → 0.217.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.215.7",
3
+ "version": "0.217.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -194,7 +194,7 @@
194
194
  "./package.json": "./package.json"
195
195
  },
196
196
  "dependencies": {
197
- "@cosmicdrift/kumiko-types": "0.215.7",
197
+ "@cosmicdrift/kumiko-types": "0.217.0",
198
198
  "bullmq": "^5.76.7",
199
199
  "bun-types": "^1.3.13",
200
200
  "hono": "^4.13.1",
@@ -210,7 +210,7 @@
210
210
  "zod": "^4.4.3"
211
211
  },
212
212
  "devDependencies": {
213
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.7",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.217.0",
214
214
  "bun-types": "^1.3.13",
215
215
  "pino-pretty": "^13.1.3"
216
216
  },
@@ -9,7 +9,7 @@ import { createSystemUser } from "../engine/system-user";
9
9
  import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
10
10
  import { NotFoundError } from "../errors";
11
11
  import type { Dispatcher } from "../pipeline/dispatcher";
12
- import { parseStringArrayJson } from "../utils/parse-string-array-json";
12
+ import { parseRoles } from "../utils/serialization";
13
13
  import { Routes } from "./api-constants";
14
14
  import {
15
15
  AUTH_COOKIE_NAME,
@@ -1361,11 +1361,10 @@ export function createAuthRoutes(
1361
1361
  config.userQuery,
1362
1362
  { id: user.id },
1363
1363
  createSystemUser(user.tenantId),
1364
- )) as { roles?: string | null } | null;
1365
- const raw = userRow?.roles;
1366
- if (typeof raw === "string" && raw.length > 0) {
1367
- globalRoles = parseStringArrayJson(raw);
1368
- }
1364
+ )) as { roles?: unknown } | null;
1365
+ // roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
1366
+ // JSON-encoded text from pre-0.217.0 rows.
1367
+ globalRoles = parseRoles(userRow?.roles ?? null);
1369
1368
  } catch (e) {
1370
1369
  // Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
1371
1370
  // läuft weiter mit nur tenant-rollen. Server-error mit nur dem
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { findForbiddenRoleAssignment } from "../role-assignment";
3
+
4
+ describe("role assignment guard", () => {
5
+ test("rejects roles above the actor's highest role", () => {
6
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"])).toBe("TenantAdmin");
7
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"])).toBe("SystemAdmin");
8
+ });
9
+
10
+ test("allows equal or lower roles, including self-updates", () => {
11
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"])).toBeUndefined();
12
+ expect(findForbiddenRoleAssignment(["Admin"], ["Editor"])).toBeUndefined();
13
+ expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"])).toBeUndefined();
14
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"])).toBeUndefined();
15
+ expect(findForbiddenRoleAssignment(["Admin"], ["Admin"])).toBeUndefined();
16
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"])).toBeUndefined();
17
+ expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"])).toBeUndefined();
18
+ });
19
+
20
+ test("rejects Editor above User, Admin above Editor", () => {
21
+ expect(findForbiddenRoleAssignment(["User"], ["Editor"])).toBe("Editor");
22
+ expect(findForbiddenRoleAssignment(["Editor"], ["Admin"])).toBe("Admin");
23
+ });
24
+
25
+ test("rejects unknown roles (fail-closed)", () => {
26
+ expect(findForbiddenRoleAssignment(["User"], ["Custom"])).toBe("Custom");
27
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"])).toBe("Custom");
28
+ });
29
+
30
+ test("rejects assignment if actor has no roles or only unknown roles", () => {
31
+ expect(findForbiddenRoleAssignment([], ["User"])).toBe("User");
32
+ expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"])).toBe("User");
33
+ });
34
+
35
+ test("rejects modifying target user with higher existing role", () => {
36
+ expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["SystemAdmin"])).toBe("SystemAdmin");
37
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["TenantAdmin"])).toBeUndefined();
38
+ });
39
+
40
+ test("allows modifying target that currently holds an unranked app role", () => {
41
+ // Editor is ranked (invite options); use a true app-defined role here.
42
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["Billing"])).toBeUndefined();
43
+ expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["Billing", "User"])).toBeUndefined();
44
+ // Still cannot assign the unranked role on the write path (fail-closed).
45
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBe("Billing");
46
+ });
47
+ });
@@ -229,6 +229,7 @@ export { readClaim } from "./read-claim";
229
229
  export { createRegistry } from "./registry";
230
230
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param";
231
231
  export { resolveConfigOrParam } from "./resolve-config-or-param";
232
+ export { findForbiddenRoleAssignment } from "./role-assignment";
232
233
  export { runsInLane } from "./run-in";
233
234
  export type { StepListOutcome } from "./run-pipeline";
234
235
  export { runPipeline, runStepList } from "./run-pipeline";
@@ -0,0 +1,45 @@
1
+ const ROLE_RANKS: Readonly<Record<string, number>> = {
2
+ User: 0,
3
+ // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
4
+ Editor: 1,
5
+ Admin: 2,
6
+ TenantAdmin: 3,
7
+ SystemAdmin: 4,
8
+ system: 5,
9
+ };
10
+
11
+ function getRoleRank(role: string): number {
12
+ return ROLE_RANKS[role] ?? Number.POSITIVE_INFINITY;
13
+ }
14
+
15
+ function maxRoleRank(roles: readonly string[]): number {
16
+ if (roles.length === 0) return -1;
17
+ return Math.max(...roles.map((role) => ROLE_RANKS[role] ?? -1));
18
+ }
19
+
20
+ /** Known built-in rank only — unranked app roles (Billing, …) are not privilege tiers. */
21
+ function knownRoleRank(role: string): number | undefined {
22
+ return ROLE_RANKS[role];
23
+ }
24
+
25
+ export function findForbiddenRoleAssignment(
26
+ actorRoles: readonly string[],
27
+ assignedRoles: readonly string[],
28
+ targetCurrentRoles: readonly string[] = [],
29
+ ): string | undefined {
30
+ const actorRank = maxRoleRank(actorRoles);
31
+ // Assign path: fail-closed on unknown / above-actor roles.
32
+ const forbiddenAssigned = assignedRoles.find((role) => getRoleRank(role) > actorRank);
33
+ if (forbiddenAssigned) return forbiddenAssigned;
34
+
35
+ // Target path: only ranked roles above the actor block (can't touch a
36
+ // SystemAdmin). Unranked app roles must not block demotion/updates — invite
37
+ // can assign them; treating them as rank ∞ on the target freezes those members.
38
+ const forbiddenTarget = targetCurrentRoles.find((role) => {
39
+ const rank = knownRoleRank(role);
40
+ return rank !== undefined && rank > actorRank;
41
+ });
42
+ if (forbiddenTarget) return forbiddenTarget;
43
+
44
+ return undefined;
45
+ }