144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import type { Context } from "@wrnexus/core";
|
|
import { createDb } from "@wrnexus/db";
|
|
import { sqlite } from "@wrnexus/db/sqlite";
|
|
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
|
|
import {
|
|
authzMiddleware,
|
|
can,
|
|
cachedPermissionStore,
|
|
defineAuthz,
|
|
guardPermission,
|
|
memoryAuditSink,
|
|
mergeCatalogs,
|
|
} from "../src/index.ts";
|
|
|
|
// Exercises the full composition end to end: db-backed store -> cache
|
|
// decorator -> merged catalog -> per-request middleware -> can()/guardPermission()
|
|
// -> audit sink. Each piece already has unit coverage elsewhere; this file is
|
|
// only about the seams between them.
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "showcase.ts",
|
|
module: defineAuthz({
|
|
permissions: {
|
|
"post:read": { public: true },
|
|
"post:write": {},
|
|
"post:delete": { risk: "high" },
|
|
},
|
|
roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] },
|
|
policies: {
|
|
ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
|
|
r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
|
|
},
|
|
bindings: { "post:delete": ["ownsPost"] },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
function makeCtx(user: unknown, tenantId?: string): Context {
|
|
return {
|
|
user,
|
|
tenant: tenantId ? { id: tenantId } : undefined,
|
|
locals: {},
|
|
url: new URL("http://localhost/"),
|
|
req: new Request("http://localhost/"),
|
|
} as unknown as Context;
|
|
}
|
|
|
|
describe("end-to-end authorization", () => {
|
|
test("db store, cache, catalog, middleware, and audit compose", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 });
|
|
const audit = memoryAuditSink();
|
|
await store.assignRole("alice", "admin", { tenantId: "acme" });
|
|
|
|
const alice = makeCtx({ id: "alice" }, "acme");
|
|
await authzMiddleware({ catalog, store, audit, strict: true })(
|
|
alice,
|
|
async () => new Response("ok"),
|
|
);
|
|
|
|
expect(await can(alice, "post:write")).toBe(true);
|
|
expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true);
|
|
expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false);
|
|
|
|
// Wrong tenant: the admin role was scoped to acme.
|
|
const elsewhere = makeCtx({ id: "alice" }, "other");
|
|
await authzMiddleware({ catalog, store, strict: true })(
|
|
elsewhere,
|
|
async () => new Response("ok"),
|
|
);
|
|
expect(await can(elsewhere, "post:write")).toBe(false);
|
|
|
|
// Anonymous can still read, because post:read is public.
|
|
const guest = makeCtx(null);
|
|
await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok"));
|
|
expect(await can(guest, "post:read")).toBe(true);
|
|
expect(await can(guest, "post:write")).toBe(false);
|
|
|
|
// Only denials were audited, and only alice's requests used the resolver
|
|
// that was connected to this audit sink.
|
|
expect(audit.events.length).toBeGreaterThan(0);
|
|
expect(audit.events.every((event) => !event.allowed)).toBe(true);
|
|
});
|
|
|
|
test("revoking a role takes effect immediately through the cache", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 });
|
|
await store.assignRole("bob", "editor");
|
|
|
|
const before = makeCtx({ id: "bob" });
|
|
await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok"));
|
|
expect(await can(before, "post:write")).toBe(true);
|
|
|
|
await store.revokeRole("bob", "editor");
|
|
|
|
const after = makeCtx({ id: "bob" });
|
|
await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok"));
|
|
expect(await can(after, "post:write")).toBe(false);
|
|
});
|
|
|
|
test("guardPermission returns an opaque 403", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const ctx = makeCtx({ id: "carol" });
|
|
await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })(
|
|
ctx,
|
|
async () => new Response("ok"),
|
|
);
|
|
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
|
|
});
|
|
|
|
test("a public permission still runs its bound policy, including for an anonymous caller", async () => {
|
|
const publicPolicyCatalog = mergeCatalogs([
|
|
{
|
|
source: "public-policy.ts",
|
|
module: defineAuthz({
|
|
permissions: { "post:preview": { public: true } },
|
|
policies: {
|
|
notBanned: async (_s: { id?: string } | null | undefined, r?: { banned?: boolean }) =>
|
|
r?.banned ? { allowed: false, reason: "resource banned" } : { allowed: true },
|
|
},
|
|
bindings: { "post:preview": ["notBanned"] },
|
|
}),
|
|
},
|
|
]);
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const store = dbPermissionStore(db);
|
|
|
|
const guest = makeCtx(null);
|
|
await authzMiddleware({ catalog: publicPolicyCatalog, store, strict: true })(
|
|
guest,
|
|
async () => new Response("ok"),
|
|
);
|
|
expect(await can(guest, "post:preview", { banned: false })).toBe(true);
|
|
expect(await can(guest, "post:preview", { banned: true })).toBe(false);
|
|
});
|
|
});
|