@cosmicdrift/kumiko-framework 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.87.1",
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.1",
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
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AccessDeniedError } from "../errors";
3
+ import { crossTenantOverrideDenied } from "./cross-tenant";
4
+ import type { SessionUser } from "./types";
5
+
6
+ const KEY = "feature.errors.tenantOverrideRequiresSystemAdmin";
7
+
8
+ function user(roles: string[]): SessionUser {
9
+ return { id: "u", tenantId: "t1" as SessionUser["tenantId"], roles };
10
+ }
11
+
12
+ describe("crossTenantOverrideDenied", () => {
13
+ test("allows when no override is requested", () => {
14
+ expect(crossTenantOverrideDenied(user(["TenantAdmin"]), undefined, KEY)).toBeUndefined();
15
+ });
16
+
17
+ test("allows a SystemAdmin to target another tenant", () => {
18
+ expect(crossTenantOverrideDenied(user(["SystemAdmin"]), "other-tenant", KEY)).toBeUndefined();
19
+ });
20
+
21
+ test("denies a TenantAdmin targeting another tenant", () => {
22
+ const denied = crossTenantOverrideDenied(user(["TenantAdmin"]), "other-tenant", KEY);
23
+ expect(denied).toBeInstanceOf(AccessDeniedError);
24
+ expect(denied?.code).toBe("access_denied");
25
+ });
26
+
27
+ test("denies an Admin too — only SystemAdmin clears the override", () => {
28
+ expect(crossTenantOverrideDenied(user(["Admin", "TenantAdmin"]), "other", KEY)).toBeInstanceOf(
29
+ AccessDeniedError,
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,22 @@
1
+ import { AccessDeniedError } from "../errors";
2
+ import type { SessionUser } from "./types";
3
+
4
+ // A payload-supplied target tenant (tenantIdOverride) on a TenantAdmin-reachable
5
+ // handler is the cross-tenant escape hatch: hasAccess passes the handler for any
6
+ // TenantAdmin, so without a SystemAdmin gate a TenantAdmin could act on another
7
+ // tenant. Centralizes the check the override handlers used to inline. Returns
8
+ // the denial to `throw` (queries) or wrap in `writeFailure` (writes), or
9
+ // undefined when allowed. The i18nKey stays per-feature so existing
10
+ // translations keep resolving.
11
+ export function crossTenantOverrideDenied(
12
+ user: SessionUser,
13
+ tenantIdOverride: string | undefined,
14
+ i18nKey: string,
15
+ ): AccessDeniedError | undefined {
16
+ if (tenantIdOverride === undefined) return undefined;
17
+ if (user.roles.includes("SystemAdmin")) return undefined;
18
+ return new AccessDeniedError({
19
+ i18nKey,
20
+ details: { reason: "tenant_override_requires_system_admin" },
21
+ });
22
+ }
@@ -38,6 +38,7 @@ export {
38
38
  } from "./constants";
39
39
  export type { App, AppConfig } from "./create-app";
40
40
  export { createApp } from "./create-app";
41
+ export { crossTenantOverrideDenied } from "./cross-tenant";
41
42
  export { defineFeature } from "./define-feature";
42
43
  export type {
43
44
  QueryHandlerDefinition,
@@ -166,6 +167,12 @@ export {
166
167
  checkWriteFieldRoles,
167
168
  filterReadFields,
168
169
  } from "./field-access";
170
+ export {
171
+ FORBIDDEN_MEMBERSHIP_ROLES,
172
+ findForbiddenMembershipRole,
173
+ isForbiddenMembershipRole,
174
+ stripForbiddenMembershipRoles,
175
+ } from "./membership-roles";
169
176
  export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
170
177
  export { from } from "./ownership";
171
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
+ }