From 93d1e3574a1130295e954d6a7bb314849657ad7c Mon Sep 17 00:00:00 2001 From: AmaraNecib Date: Sun, 19 Jul 2026 12:48:25 +0100 Subject: [PATCH] 25: role-derived access and permission patterns (closes #25) --- packages/core/__tests__/decision.test.ts | 247 +++++++++++++++++++++++ packages/core/__tests__/smoke.test.ts | 8 +- packages/core/src/index.ts | 37 +++- 3 files changed, 288 insertions(+), 4 deletions(-) diff --git a/packages/core/__tests__/decision.test.ts b/packages/core/__tests__/decision.test.ts index 8a9dbc0..a2fa89a 100644 --- a/packages/core/__tests__/decision.test.ts +++ b/packages/core/__tests__/decision.test.ts @@ -3,6 +3,7 @@ import { createMizan, Mizan, PrincipalEvaluator, + matchesPermission, type SourceResolver, type AuthorizationFact, } from "../src/index.ts"; @@ -25,6 +26,252 @@ function emptySource(): SourceResolver { }; } +// ─── matchesPermission() ─────────────────────────────────────────────────── + +describe("matchesPermission()", () => { + it("exact match returns true", () => { + expect(matchesPermission("files.read", "files.read")).toBe(true); + }); + + it("different exact permission returns false", () => { + expect(matchesPermission("files.read", "files.write")).toBe(false); + }); + + it("global pattern matches everything", () => { + expect(matchesPermission("anything", "*")).toBe(true); + expect(matchesPermission("files.read", "*")).toBe(true); + expect(matchesPermission("admin.view", "*")).toBe(true); + }); + + it("namespace pattern matches permissions under that prefix", () => { + expect(matchesPermission("files.read", "files.*")).toBe(true); + expect(matchesPermission("files.write", "files.*")).toBe(true); + expect(matchesPermission("files.sub.delete", "files.*")).toBe(true); + expect(matchesPermission("files", "files.*")).toBe(true); + }); + + it("namespace pattern does not match unrelated permissions", () => { + expect(matchesPermission("admin.view", "files.*")).toBe(false); + expect(matchesPermission("firefiles.read", "files.*")).toBe(false); + }); + + it("namespace pattern enforces dot boundary (rejects prefix-like names)", () => { + // "filesX.read" starts with "files" but is outside the "files.*" namespace + expect(matchesPermission("filesX.read", "files.*")).toBe(false); + }); + + it("empty permission does not match non-global pattern", () => { + expect(matchesPermission("", "files.*")).toBe(false); + }); +}); + +// ─── Pattern-based evaluation ───────────────────────────────────────────── + +describe("PrincipalEvaluator with patterns", () => { + it("global grant allows any permission", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "*", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("anything")).toBe(true); + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("admin.view")).toBe(true); + }); + + it("namespace grant allows permissions under that namespace", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "files.*", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("files.write")).toBe(true); + expect(await auth.can("admin.view")).toBe(false); + }); + + it("namespace denial overrides specific exact grant", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant" }, + { permission: "files.*", effect: "deny" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(false); + }); + + it("exact denial overrides namespace grant", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.*", effect: "grant" }, + { permission: "files.delete", effect: "deny" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("files.delete")).toBe(false); + }); + + it("global denial overrides any grant", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "*", effect: "deny" }, + { permission: "files.read", effect: "grant" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(false); + expect(await auth.can("anything")).toBe(false); + }); + + it("pattern does not make missing permission valid (deny-by-default)", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "files.*", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + // Permission not under any matching pattern/namespace + expect(await auth.can("unknown")).toBe(false); + }); + + it("multiple namespace grants are additive", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.*", effect: "grant" }, + { permission: "admin.*", effect: "grant" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("admin.view")).toBe(true); + expect(await auth.can("other.action")).toBe(false); + }); + + it("denial from one namespace does not affect another namespace", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.*", effect: "grant" }, + { permission: "admin.*", effect: "deny" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("admin.view")).toBe(false); + }); +}); + +// ─── Role-derived access integration ─────────────────────────────────────── + +describe("Role-derived access", () => { + it("role-derived grants are additive with direct grants", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", { + async resolve(_context: { principalId?: string }) { + const roleFacts: AuthorizationFact[] = [ + { permission: "files.read", effect: "grant" }, + { permission: "files.write", effect: "grant" }, + ]; + const directFacts: AuthorizationFact[] = [ + { permission: "admin.view", effect: "grant" }, + ]; + return { status: "facts", facts: [...roleFacts, ...directFacts], freshness: "fresh" }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("files.write")).toBe(true); + expect(await auth.can("admin.view")).toBe(true); + }); + + it("direct denial overrides role-derived grant", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", { + async resolve(_context: { principalId?: string }) { + const roleFacts: AuthorizationFact[] = [ + { permission: "files.read", effect: "grant" }, + ]; + const directFacts: AuthorizationFact[] = [ + { permission: "files.read", effect: "deny" }, + ]; + return { status: "facts", facts: [...roleFacts, ...directFacts], freshness: "fresh" }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(false); + }); + + it("denying one permission does not remove unrelated permissions", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", { + async resolve(_context: { principalId?: string }) { + const roleFacts: AuthorizationFact[] = [ + { permission: "files.read", effect: "grant" }, + { permission: "files.write", effect: "grant" }, + ]; + const directFacts: AuthorizationFact[] = [ + { permission: "files.read", effect: "deny" }, + ]; + return { status: "facts", facts: [...roleFacts, ...directFacts], freshness: "fresh" }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(false); + expect(await auth.can("files.write")).toBe(true); + }); + + it("role with namespace pattern grants specific permissions", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", { + async resolve(_context: { principalId?: string }) { + const roleFacts: AuthorizationFact[] = [ + { permission: "files.*", effect: "grant" }, + ]; + return { status: "facts", facts: roleFacts, freshness: "fresh" }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("files.write")).toBe(true); + expect(await auth.can("admin.view")).toBe(false); + }); + + it("multiple roles produce additive grants", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", { + async resolve(_context: { principalId?: string }) { + const roleAFacts: AuthorizationFact[] = [ + { permission: "files.*", effect: "grant" }, + ]; + const roleBFacts: AuthorizationFact[] = [ + { permission: "admin.view", effect: "grant" }, + ]; + return { status: "facts", facts: [...roleAFacts, ...roleBFacts], freshness: "fresh" }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("admin.view")).toBe(true); + }); +}); + // ─── PrincipalEvaluator: can() ───────────────────────────────────────────── describe("PrincipalEvaluator.can()", () => { diff --git a/packages/core/__tests__/smoke.test.ts b/packages/core/__tests__/smoke.test.ts index d5c0842..22381d7 100644 --- a/packages/core/__tests__/smoke.test.ts +++ b/packages/core/__tests__/smoke.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { createMizan, can, decide, Mizan } from "../src/index.ts"; +import { createMizan, can, decide, Mizan, matchesPermission } from "../src/index.ts"; describe("@mizan/core", () => { it("exports createMizan", () => { @@ -31,4 +31,10 @@ describe("@mizan/core", () => { expect(mizan).toBeDefined(); expect(mizan.constructor.name).toBe("Mizan"); }); + + it("exports matchesPermission", () => { + expect(matchesPermission).toBeInstanceOf(Function); + expect(matchesPermission("a", "*")).toBe(true); + expect(matchesPermission("a", "b")).toBe(false); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8035a60..5274238 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -201,6 +201,37 @@ export interface SourcePlan { readonly sources: SourcePlanEntry[]; } +// ─── Permission pattern matching ──────────────────────────────────────────── + +/** + * Check whether a permission key matches a pattern. + * + * Supported patterns: + * - **Exact**: `"files.read"` matches only `"files.read"`. + * - **Global**: `"*"` matches any permission. + * - **Namespace**: `"files.*"` matches `"files.read"`, `"files.write"`, + * `"files.sub.delete"`, etc. + * + * Patterns are deliberately limited to these three forms. Arbitrary glob + * or regular-expression semantics are not part of the core contract. + * + * @param permission - The concrete permission key to check (e.g., `"files.read"`). + * @param pattern - The pattern to match against (e.g., `"files.*"` or `"*"`). + * @returns `true` if the permission matches the pattern. + */ +export function matchesPermission(permission: string, pattern: string): boolean { + if (pattern === "*") { + return true; + } + if (pattern.endsWith(".*") && pattern.length > 2) { + // Remove only the "*" to keep the dot: "files.*" → prefix "files." + const prefix = pattern.slice(0, -1); + const bare = prefix.slice(0, -1); + return permission === bare || permission.startsWith(prefix); + } + return permission === pattern; +} + // ─── Public API ──────────────────────────────────────────────────────────── /** @@ -307,8 +338,8 @@ async function collectFacts( /** * Evaluate all facts against a single permission and return the decision. * - * v0.1 logic (simplified): - * 1. Filter to exact-matching facts. + * v0.1 logic: + * 1. Filter to pattern-matching facts (exact, global `*`, or namespace `files.*`). * 2. If any matching denial exists → deny (matching-denial). * 3. If any matching grant exists → allow. * 4. Otherwise → deny (no-grant). @@ -317,7 +348,7 @@ function evaluate( facts: AuthorizationFact[], permission: string, ): AuthorizationResult { - const matching = facts.filter((f) => f.permission === permission); + const matching = facts.filter((f) => matchesPermission(permission, f.permission)); const hasDenial = matching.some((f) => f.effect === "deny"); if (hasDenial) {