@cosmicdrift/kumiko-framework 0.87.2 → 0.88.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.
|
|
3
|
+
"version": "0.88.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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.88.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
+
}
|