Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions packages/core/__tests__/decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createMizan,
Mizan,
PrincipalEvaluator,
matchesPermission,
type SourceResolver,
type AuthorizationFact,
} from "../src/index.ts";
Expand All @@ -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()", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/__tests__/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
37 changes: 34 additions & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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).
Expand All @@ -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) {
Expand Down
Loading