feat(authz): merge declaration modules into a frozen catalog
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
|
||||||
|
import type { DecisionPolicy } from "./advanced.ts";
|
||||||
|
|
||||||
|
export interface CatalogSource {
|
||||||
|
/** File or package that declared this module, used in conflict messages. */
|
||||||
|
source: string;
|
||||||
|
module: AuthzModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural equality for declaration metadata. Key order is irrelevant. */
|
||||||
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (Object.is(a, b)) return true;
|
||||||
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
||||||
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||||
|
const left = a as Record<string, unknown>;
|
||||||
|
const right = b as Record<string, unknown>;
|
||||||
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
||||||
|
for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */
|
||||||
|
function frozenMap<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
|
||||||
|
const map = new Map(entries);
|
||||||
|
const reject = () => {
|
||||||
|
throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot.");
|
||||||
|
};
|
||||||
|
map.set = reject as never;
|
||||||
|
map.delete = reject as never;
|
||||||
|
map.clear = reject as never;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyCatalog(): AuthzCatalog {
|
||||||
|
return {
|
||||||
|
permissions: frozenMap<PermissionMeta>([]),
|
||||||
|
roles: frozenMap<readonly string[]>([]),
|
||||||
|
policies: frozenMap<DecisionPolicy<never, never>>([]),
|
||||||
|
attributes: frozenMap<AttributeMeta>([]),
|
||||||
|
bindings: frozenMap<readonly string[]>([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
|
||||||
|
const permissions = new Map<string, PermissionMeta>();
|
||||||
|
const roles = new Map<string, readonly string[]>();
|
||||||
|
const policies = new Map<string, DecisionPolicy<never, never>>();
|
||||||
|
const attributes = new Map<string, AttributeMeta>();
|
||||||
|
const bindings = new Map<string, Set<string>>();
|
||||||
|
const origin = new Map<string, string>();
|
||||||
|
|
||||||
|
const claim = (
|
||||||
|
kind: string,
|
||||||
|
key: string,
|
||||||
|
source: string,
|
||||||
|
existingValue: unknown,
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
const previous = origin.get(`${kind}:${key}`);
|
||||||
|
if (previous === undefined) {
|
||||||
|
origin.set(`${kind}:${key}`, source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deepEqual(existingValue, value)) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const { source, module } of sources) {
|
||||||
|
for (const [id, meta] of Object.entries(module.permissions ?? {})) {
|
||||||
|
claim("permission", id, source, permissions.get(id), meta);
|
||||||
|
permissions.set(id, meta);
|
||||||
|
}
|
||||||
|
for (const [name, grants] of Object.entries(module.roles ?? {})) {
|
||||||
|
claim("role", name, source, roles.get(name), grants);
|
||||||
|
roles.set(name, grants);
|
||||||
|
}
|
||||||
|
for (const [name, policy] of Object.entries(module.policies ?? {})) {
|
||||||
|
// Two closures are never deep-equal, so identity is the only sane test.
|
||||||
|
const existing = policies.get(name);
|
||||||
|
if (existing && existing !== policy) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
origin.set(`policy:${name}`, source);
|
||||||
|
policies.set(name, policy);
|
||||||
|
}
|
||||||
|
for (const [name, meta] of Object.entries(module.attributes ?? {})) {
|
||||||
|
claim("attribute", name, source, attributes.get(name), meta);
|
||||||
|
attributes.set(name, meta);
|
||||||
|
}
|
||||||
|
for (const [permission, names] of Object.entries(module.bindings ?? {})) {
|
||||||
|
const set = bindings.get(permission) ?? new Set<string>();
|
||||||
|
for (const name of names) set.add(name);
|
||||||
|
bindings.set(permission, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [permission, names] of bindings) {
|
||||||
|
for (const name of names) {
|
||||||
|
if (!policies.has(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions: frozenMap(permissions),
|
||||||
|
roles: frozenMap(roles),
|
||||||
|
policies: frozenMap(policies),
|
||||||
|
attributes: frozenMap(attributes),
|
||||||
|
bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { defineAuthz } from "../src/registry.ts";
|
||||||
|
import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts";
|
||||||
|
|
||||||
|
describe("mergeCatalogs", () => {
|
||||||
|
test("merges disjoint modules", () => {
|
||||||
|
const catalog = mergeCatalogs([
|
||||||
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) },
|
||||||
|
{ source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) },
|
||||||
|
]);
|
||||||
|
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-declaring a permission with deep-equal metadata is a no-op", () => {
|
||||||
|
const meta = { title: "View posts", risk: "low" as const };
|
||||||
|
const catalog = mergeCatalogs([
|
||||||
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) },
|
||||||
|
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) },
|
||||||
|
]);
|
||||||
|
expect(catalog.permissions.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conflicting metadata is a boot error naming both files", () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeCatalogs([
|
||||||
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) },
|
||||||
|
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) },
|
||||||
|
]),
|
||||||
|
).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conflicting role definitions are a boot error", () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeCatalogs([
|
||||||
|
{ source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) },
|
||||||
|
{ source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) },
|
||||||
|
]),
|
||||||
|
).toThrow(/editor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bindings for the same permission union across modules", () => {
|
||||||
|
const p1 = defineAuthz({
|
||||||
|
permissions: { "post:write": {} },
|
||||||
|
policies: { ownsPost: async () => ({ allowed: true }) },
|
||||||
|
bindings: { "post:write": ["ownsPost"] },
|
||||||
|
});
|
||||||
|
const p2 = defineAuthz({
|
||||||
|
policies: { notLocked: async () => ({ allowed: true }) },
|
||||||
|
bindings: { "post:write": ["notLocked"] },
|
||||||
|
});
|
||||||
|
const catalog = mergeCatalogs([
|
||||||
|
{ source: "a.ts", module: p1 },
|
||||||
|
{ source: "b.ts", module: p2 },
|
||||||
|
]);
|
||||||
|
expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a binding referencing a policy no module declares is a boot error", () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeCatalogs([
|
||||||
|
{
|
||||||
|
source: "a.ts",
|
||||||
|
module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toThrow(/ghost/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the merged catalog is frozen", () => {
|
||||||
|
const catalog = mergeCatalogs([]);
|
||||||
|
expect(() => (catalog.permissions as Map<string, never>).set("x:y", {} as never)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emptyCatalog has no entries", () => {
|
||||||
|
expect(emptyCatalog().permissions.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user