@cosmicdrift/kumiko-framework 0.87.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.87.2",
3
+ "version": "0.87.3",
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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.87.2",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.3",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -2,6 +2,7 @@ import type { Context } from "hono";
2
2
  import { Hono } from "hono";
3
3
  import { deleteCookie, setCookie } from "hono/cookie";
4
4
  import { z } from "zod";
5
+ import { stripForbiddenMembershipRoles } from "../engine/membership-roles";
5
6
  import { createSystemUser } from "../engine/system-user";
6
7
  import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
7
8
  import { NotFoundError } from "../errors";
@@ -902,7 +903,13 @@ export function createAuthRoutes(
902
903
  // tenant A accidentally surviving into tenant B's session). The
903
904
  // resolver runs each feature's r.authClaims() hook under the new
904
905
  // TenantDb scope.
905
- const mergedRoles = Array.from(new Set([...globalRoles, ...membership.roles]));
906
+ // Strip reserved roles from the membership portion only — globalRoles
907
+ // (where SystemAdmin legitimately lives) is never filtered. Backstop for a
908
+ // membership role that a projection rebuild resurrected past command-time
909
+ // validation (see engine/membership-roles).
910
+ const mergedRoles = Array.from(
911
+ new Set([...globalRoles, ...stripForbiddenMembershipRoles(membership.roles)]),
912
+ );
906
913
  const targetSession: SessionUser = {
907
914
  id: user.id,
908
915
  tenantId: targetTenantId,
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ FORBIDDEN_MEMBERSHIP_ROLES,
4
+ findForbiddenMembershipRole,
5
+ isForbiddenMembershipRole,
6
+ stripForbiddenMembershipRoles,
7
+ } from "../membership-roles";
8
+
9
+ describe("forbidden membership roles", () => {
10
+ test("set covers the platform-global/reserved roles", () => {
11
+ expect([...FORBIDDEN_MEMBERSHIP_ROLES].sort()).toEqual(
12
+ ["SystemAdmin", "all", "anonymous", "system"].sort(),
13
+ );
14
+ });
15
+
16
+ test("isForbiddenMembershipRole flags reserved, allows tenant roles", () => {
17
+ expect(isForbiddenMembershipRole("SystemAdmin")).toBe(true);
18
+ expect(isForbiddenMembershipRole("system")).toBe(true);
19
+ expect(isForbiddenMembershipRole("all")).toBe(true);
20
+ expect(isForbiddenMembershipRole("anonymous")).toBe(true);
21
+ expect(isForbiddenMembershipRole("Admin")).toBe(false);
22
+ expect(isForbiddenMembershipRole("User")).toBe(false);
23
+ });
24
+
25
+ test("findForbiddenMembershipRole returns the first reserved role or undefined", () => {
26
+ expect(findForbiddenMembershipRole(["Admin", "SystemAdmin", "User"])).toBe("SystemAdmin");
27
+ expect(findForbiddenMembershipRole(["Admin", "User"])).toBeUndefined();
28
+ });
29
+
30
+ test("strip removes reserved roles, preserves order of the rest", () => {
31
+ expect(stripForbiddenMembershipRoles(["Admin", "SystemAdmin", "User", "all"])).toEqual([
32
+ "Admin",
33
+ "User",
34
+ ]);
35
+ expect(stripForbiddenMembershipRoles(["Editor", "User"])).toEqual(["Editor", "User"]);
36
+ expect(stripForbiddenMembershipRoles(["SystemAdmin"])).toEqual([]);
37
+ });
38
+ });
39
+
40
+ // The two cases that discriminate the fix at every JWT mint: the strip wraps
41
+ // ONLY the membership portion, never the merged result — so a legitimate
42
+ // SystemAdmin in globalRoles survives, a resurrected one in membership does not.
43
+ describe("merge semantics (globalRoles never filtered)", () => {
44
+ function merge(
45
+ globalRoles: readonly string[],
46
+ membershipRoles: readonly string[],
47
+ ): readonly string[] {
48
+ return Array.from(new Set([...globalRoles, ...stripForbiddenMembershipRoles(membershipRoles)]));
49
+ }
50
+
51
+ test("global SystemAdmin survives (no regression for real admins)", () => {
52
+ expect(merge(["SystemAdmin"], [])).toContain("SystemAdmin");
53
+ });
54
+
55
+ test("membership SystemAdmin is stripped (resurrected role neutralised)", () => {
56
+ expect(merge([], ["SystemAdmin"])).not.toContain("SystemAdmin");
57
+ });
58
+
59
+ test("global admin + tenant membership keeps both, deduped", () => {
60
+ expect([...merge(["SystemAdmin"], ["Admin", "SystemAdmin"])].sort()).toEqual(
61
+ ["Admin", "SystemAdmin"].sort(),
62
+ );
63
+ });
64
+ });
@@ -167,6 +167,12 @@ export {
167
167
  checkWriteFieldRoles,
168
168
  filterReadFields,
169
169
  } from "./field-access";
170
+ export {
171
+ FORBIDDEN_MEMBERSHIP_ROLES,
172
+ findForbiddenMembershipRole,
173
+ isForbiddenMembershipRole,
174
+ stripForbiddenMembershipRoles,
175
+ } from "./membership-roles";
170
176
  export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
171
177
  export { from } from "./ownership";
172
178
  export { buildPipelineSteps, pipeline } from "./pipeline";
@@ -0,0 +1,32 @@
1
+ // Reserved/platform-global roles must never reach a session via a tenant
2
+ // membership. hasAccess checks session.roles flat, with no notion of origin,
3
+ // so a membership role like "SystemAdmin" would unlock the SystemAdmin-gated
4
+ // cross-tenant surface. The write paths enforce this at command time
5
+ // (assertAssignableMembershipRoles), but command-time validation does not
6
+ // survive a projection rebuild: replaying a stored membership event goes
7
+ // through the apply path, not the handler. stripForbiddenMembershipRoles is
8
+ // the read-time backstop — applied at every JWT mint that derives roles from
9
+ // membership, it neutralises a resurrected role without touching globalRoles
10
+ // (where SystemAdmin legitimately lives).
11
+
12
+ import { access } from "./config-helpers";
13
+
14
+ export const FORBIDDEN_MEMBERSHIP_ROLES: ReadonlySet<string> = new Set<string>([
15
+ ...access.privileged, // system, SystemAdmin
16
+ ...access.all, // all
17
+ ...access.anonymous, // anonymous
18
+ ]);
19
+
20
+ export function isForbiddenMembershipRole(role: string): boolean {
21
+ return FORBIDDEN_MEMBERSHIP_ROLES.has(role);
22
+ }
23
+
24
+ export function findForbiddenMembershipRole(roles: readonly string[]): string | undefined {
25
+ return roles.find(isForbiddenMembershipRole);
26
+ }
27
+
28
+ // Filters reserved roles out of the membership portion only. Callers merge the
29
+ // result with globalRoles, which is never filtered.
30
+ export function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[] {
31
+ return roles.filter((role) => !isForbiddenMembershipRole(role));
32
+ }