feat(authz): add defineAuthz declaration registry

This commit is contained in:
2026-08-04 16:26:17 +05:30
parent 0ac648bc26
commit 212fdaa5b5
3 changed files with 136 additions and 0 deletions
+50
View File
@@ -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 });
}
+45
View File
@@ -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[];
}
+41
View File
@@ -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/);
});
});