From 212fdaa5b537b2d8d45661bb7a654cbc9301f1b0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:26:17 +0530 Subject: [PATCH] feat(authz): add defineAuthz declaration registry --- packages/authz/src/registry.ts | 50 ++++++++++++++++++++++++++++ packages/authz/src/types.ts | 45 +++++++++++++++++++++++++ packages/authz/test/registry.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 packages/authz/src/registry.ts create mode 100644 packages/authz/src/types.ts create mode 100644 packages/authz/test/registry.test.ts diff --git a/packages/authz/src/registry.ts b/packages/authz/src/registry.ts new file mode 100644 index 00000000..d7a21ebc --- /dev/null +++ b/packages/authz/src/registry.ts @@ -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/.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:'.`, + ); + } + } + } + + 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 }); +} diff --git a/packages/authz/src/types.ts b/packages/authz/src/types.ts new file mode 100644 index 00000000..2dd7b17c --- /dev/null +++ b/packages/authz/src/types.ts @@ -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/.ts` declaration. */ +export interface AuthzModule { + permissions?: Record; + roles?: Record; + policies?: Record>; + attributes?: Record; + /** permission id -> policy names that must pass for it. */ + bindings?: Record; +} + +/** The merged, frozen view of every declaration in the app. */ +export interface AuthzCatalog { + permissions: ReadonlyMap; + roles: ReadonlyMap; + policies: ReadonlyMap>; + attributes: ReadonlyMap; + bindings: ReadonlyMap; +} + +export interface SubjectAssignments { + roles: string[]; + /** Explicit allows, bypassing roles. */ + grants: string[]; + /** Explicit denies. Win over everything, including "*". */ + denies: string[]; +} diff --git a/packages/authz/test/registry.test.ts b/packages/authz/test/registry.test.ts new file mode 100644 index 00000000..8fef1d4f --- /dev/null +++ b/packages/authz/test/registry.test.ts @@ -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/); + }); +});