feat(authz): add PermissionStore contract with memory adapter and conformance suite
This commit is contained in:
@@ -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<SubjectAssignments>;
|
||||||
|
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
|
||||||
|
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
|
||||||
|
grant(
|
||||||
|
subjectId: string,
|
||||||
|
permission: string,
|
||||||
|
effect: GrantEffect,
|
||||||
|
scope?: AuthzScope,
|
||||||
|
): Promise<void>;
|
||||||
|
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
|
||||||
|
listSubjects(scope?: AuthzScope): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string>();
|
||||||
|
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];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<PermissionStore>): 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { memoryPermissionStore } from "../src/store.ts";
|
||||||
|
import { runStoreConformance } from "./store-conformance.ts";
|
||||||
|
|
||||||
|
runStoreConformance("memory", async () => memoryPermissionStore());
|
||||||
Reference in New Issue
Block a user