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