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];
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user