feat(authz): add defineAuthz declaration registry
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AuthzModule } from "./types.ts";
|
||||||
|
|
||||||
|
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and freeze one authorization declaration. Called from
|
||||||
|
* `app/authz/<name>.ts` as the module's default export.
|
||||||
|
*/
|
||||||
|
export function defineAuthz(module: AuthzModule): AuthzModule {
|
||||||
|
const permissions = module.permissions ?? {};
|
||||||
|
const roles = module.roles ?? {};
|
||||||
|
const policies = module.policies ?? {};
|
||||||
|
const attributes = module.attributes ?? {};
|
||||||
|
const bindings = module.bindings ?? {};
|
||||||
|
|
||||||
|
for (const id of Object.keys(permissions)) {
|
||||||
|
if (id.includes("*")) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!PERMISSION_ID.test(id)) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [role, grants] of Object.entries(roles)) {
|
||||||
|
for (const grant of grants) {
|
||||||
|
if (typeof grant !== "string" || !grant.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:<name>'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [permission, names] of Object.entries(bindings)) {
|
||||||
|
for (const name of names) {
|
||||||
|
if (!(name in policies)) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ permissions, roles, policies, attributes, bindings });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { DecisionPolicy } from "./advanced.ts";
|
||||||
|
|
||||||
|
/** Narrows an assignment to a tenant. Absent means a global assignment. */
|
||||||
|
export interface AuthzScope {
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionMeta {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
risk?: "low" | "medium" | "high";
|
||||||
|
/** Granted to anonymous subjects. Every other permission denies without a user. */
|
||||||
|
public?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeMeta {
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `app/authz/<name>.ts` declaration. */
|
||||||
|
export interface AuthzModule {
|
||||||
|
permissions?: Record<string, PermissionMeta>;
|
||||||
|
roles?: Record<string, string[]>;
|
||||||
|
policies?: Record<string, DecisionPolicy<never, never>>;
|
||||||
|
attributes?: Record<string, AttributeMeta>;
|
||||||
|
/** permission id -> policy names that must pass for it. */
|
||||||
|
bindings?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merged, frozen view of every declaration in the app. */
|
||||||
|
export interface AuthzCatalog {
|
||||||
|
permissions: ReadonlyMap<string, PermissionMeta>;
|
||||||
|
roles: ReadonlyMap<string, readonly string[]>;
|
||||||
|
policies: ReadonlyMap<string, DecisionPolicy<never, never>>;
|
||||||
|
attributes: ReadonlyMap<string, AttributeMeta>;
|
||||||
|
bindings: ReadonlyMap<string, readonly string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubjectAssignments {
|
||||||
|
roles: string[];
|
||||||
|
/** Explicit allows, bypassing roles. */
|
||||||
|
grants: string[];
|
||||||
|
/** Explicit denies. Win over everything, including "*". */
|
||||||
|
denies: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { defineAuthz } from "../src/registry.ts";
|
||||||
|
|
||||||
|
describe("defineAuthz", () => {
|
||||||
|
test("returns a frozen module", () => {
|
||||||
|
const mod = defineAuthz({
|
||||||
|
permissions: { "post:read": { title: "View posts" } },
|
||||||
|
roles: { editor: ["post:*"] },
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(mod)).toBe(true);
|
||||||
|
expect(mod.permissions!["post:read"]!.title).toBe("View posts");
|
||||||
|
expect(mod.roles!.editor).toEqual(["post:*"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults missing sections to empty objects", () => {
|
||||||
|
const mod = defineAuthz({});
|
||||||
|
expect(mod.permissions).toEqual({});
|
||||||
|
expect(mod.roles).toEqual({});
|
||||||
|
expect(mod.policies).toEqual({});
|
||||||
|
expect(mod.attributes).toEqual({});
|
||||||
|
expect(mod.bindings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a permission id that is not colon-namespaced lowercase", () => {
|
||||||
|
expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i);
|
||||||
|
expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a role granting an unknown-shaped entry", () => {
|
||||||
|
expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a binding naming a policy that is not declared", () => {
|
||||||
|
expect(() =>
|
||||||
|
defineAuthz({
|
||||||
|
permissions: { "post:write": {} },
|
||||||
|
bindings: { "post:write": ["missingPolicy"] },
|
||||||
|
}),
|
||||||
|
).toThrow(/missingPolicy/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user