From 1849213ce4697c3144350f8876ad25a60e4f2168 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:36:57 +0530 Subject: [PATCH] feat(authz): add PermissionStore contract with memory adapter and conformance suite --- packages/authz/src/store.ts | 90 ++++++++++++++++++++ packages/authz/test/store-conformance.ts | 101 +++++++++++++++++++++++ packages/authz/test/store-memory.test.ts | 4 + 3 files changed, 195 insertions(+) create mode 100644 packages/authz/src/store.ts create mode 100644 packages/authz/test/store-conformance.ts create mode 100644 packages/authz/test/store-memory.test.ts diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts new file mode 100644 index 00000000..3b4f1956 --- /dev/null +++ b/packages/authz/src/store.ts @@ -0,0 +1,90 @@ +import type { AuthzScope, SubjectAssignments } from "./types.ts"; + +export type GrantEffect = "allow" | "deny"; + +export interface PermissionStore { + assignmentsFor(subjectId: string, scope?: AuthzScope): Promise; + assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + grant( + subjectId: string, + permission: string, + effect: GrantEffect, + scope?: AuthzScope, + ): Promise; + revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise; + listSubjects(scope?: AuthzScope): Promise; +} + +/** Global assignments are stored under the empty-string scope key. */ +export function scopeKey(scope?: AuthzScope): string { + return scope?.tenantId ?? ""; +} + +interface Row { + subjectId: string; + scope: string; +} +interface RoleRow extends Row { + role: string; +} +interface GrantRow extends Row { + permission: string; + effect: GrantEffect; +} + +export function memoryPermissionStore(): PermissionStore { + const roles: RoleRow[] = []; + const grants: GrantRow[] = []; + + // A request inside tenant t sees global assignments plus t's own. + const visible = (row: Row, key: string) => row.scope === "" || row.scope === key; + + return { + async assignmentsFor(subjectId, scope) { + const key = scopeKey(scope); + const mine = (row: Row) => row.subjectId === subjectId && visible(row, key); + const matched = grants.filter(mine); + return { + roles: roles.filter(mine).map((row) => row.role), + grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission), + denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission), + }; + }, + async assignRole(subjectId, role, scope) { + const key = scopeKey(scope); + if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role)) + return; + roles.push({ subjectId, scope: key, role }); + }, + async revokeRole(subjectId, role, scope) { + const key = scopeKey(scope); + const at = roles.findIndex( + (r) => r.subjectId === subjectId && r.scope === key && r.role === role, + ); + if (at !== -1) roles.splice(at, 1); + }, + async grant(subjectId, permission, effect, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + grants.push({ subjectId, scope: key, permission, effect }); + }, + async revokeGrant(subjectId, permission, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + }, + async listSubjects(scope) { + const key = scopeKey(scope); + const ids = new Set(); + for (const row of roles) if (row.scope === key) ids.add(row.subjectId); + for (const row of grants) if (row.scope === key) ids.add(row.subjectId); + return [...ids]; + }, + }; +} diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts new file mode 100644 index 00000000..1a923695 --- /dev/null +++ b/packages/authz/test/store-conformance.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { PermissionStore } from "../src/store.ts"; + +/** + * Every PermissionStore adapter must pass this suite, so the memory and db + * implementations cannot drift apart. + */ +export function runStoreConformance(name: string, makeStore: () => Promise): void { + describe(`PermissionStore conformance: ${name}`, () => { + let store: PermissionStore; + beforeEach(async () => { + store = await makeStore(); + }); + + test("an unknown subject has empty assignments", async () => { + expect(await store.assignmentsFor("nobody")).toEqual({ + roles: [], + grants: [], + denies: [], + }); + }); + + test("assignRole then assignmentsFor round-trips", async () => { + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("assignRole is idempotent", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("revokeRole removes only that role", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "admin"); + await store.revokeRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]); + }); + + test("revoking a role that was never assigned is a no-op", async () => { + await store.revokeRole("u1", "ghost"); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + }); + + test("scoped assignments do not leak across tenants", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]); + }); + + test("a global assignment is visible inside every tenant", async () => { + await store.assignRole("u1", "superadmin"); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]); + }); + + test("global and scoped roles union within a tenant", async () => { + await store.assignRole("u1", "viewer"); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([ + "editor", + "viewer", + ]); + }); + + test("grant with allow and deny land in the right buckets", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:delete", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual(["post:write"]); + expect(assignments.denies).toEqual(["post:delete"]); + }); + + test("re-granting the same permission replaces its effect", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:write", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual([]); + expect(assignments.denies).toEqual(["post:write"]); + }); + + test("revokeGrant removes the permission entirely", async () => { + await store.grant("u1", "post:write", "allow"); + await store.revokeGrant("u1", "post:write"); + expect((await store.assignmentsFor("u1")).grants).toEqual([]); + }); + + test("listSubjects returns everyone with an assignment in scope", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await store.assignRole("u2", "editor", { tenantId: "t1" }); + await store.assignRole("u3", "editor", { tenantId: "t2" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]); + }); + + test("listSubjects with no scope returns global assignees only", async () => { + await store.assignRole("g1", "viewer"); + await store.assignRole("s1", "editor", { tenantId: "t1" }); + expect(await store.listSubjects()).toEqual(["g1"]); + }); + }); +} diff --git a/packages/authz/test/store-memory.test.ts b/packages/authz/test/store-memory.test.ts new file mode 100644 index 00000000..b124b294 --- /dev/null +++ b/packages/authz/test/store-memory.test.ts @@ -0,0 +1,4 @@ +import { memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +runStoreConformance("memory", async () => memoryPermissionStore());