Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three
items in that diff plus one adjacent pre-existing issue that C1 made
reachable:
- Important (perf): permissionsFor() rebuilt the deny Set on every
entry in the granted set (O(grants x denies) allocations on a
per-request path). Hoisted to build the Set once. Measured
4000x4000: 665.92ms before, 3.90ms after.
- Important (contract accuracy): permissionsFor() only half-agrees
with decide() — a narrow deny under a broad grant (e.g. role editor's
"post:*" plus a deny on "post:delete") can't be represented in a flat
Set, so the set still contains "post:*" while decide() correctly
refuses "post:delete". Documented as NOT authoritative on the
AuthzResolver interface, and pinned with a regression test asserting
the divergence is deliberate.
- Minor: subject.id === "" was audited as subjectId: "" instead of
omitted, so consoleAuditSink printed a blank subject= rather than
subject=anonymous. Reused the same non-empty-string guard as the
decide() path.
- Important (adjacent, advanced.ts): owner() compared subject[key] to
resource[key] with Object.is without checking either side was
present, so two absent ids (Object.is(undefined, undefined) ===
true) satisfied ownership. Unreachable before this task, but C1 now
runs bound policies for anonymous/empty subjects, putting this on a
live path. Fixed to deny whenever either side is undefined or null.
Every fix's regression test was verified by reverting the fix and
confirming the test fails against the pre-fix code before restoring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { createContext } from "@wrnexus/core";
|
|
import {
|
|
defineRbac,
|
|
hasRole,
|
|
authorize,
|
|
requireRole,
|
|
requirePermission,
|
|
any,
|
|
all,
|
|
attr,
|
|
decision,
|
|
owner,
|
|
type Policy,
|
|
type Subject,
|
|
} from "../src/index.ts";
|
|
|
|
const rbac = defineRbac({
|
|
admin: ["*"],
|
|
editor: ["post:read", "post:write"],
|
|
viewer: ["post:read"],
|
|
moderator: ["role:editor", "comment:delete"], // inherits editor
|
|
});
|
|
|
|
test("RBAC: roles, wildcards, namespaces, inheritance", () => {
|
|
expect(rbac.can({ roles: ["viewer"] }, "post:read")).toBe(true);
|
|
expect(rbac.can({ roles: ["viewer"] }, "post:write")).toBe(false);
|
|
expect(rbac.can({ roles: ["admin"] }, "anything:goes")).toBe(true); // "*"
|
|
expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(true); // inherited from editor
|
|
expect(rbac.can({ roles: ["moderator"] }, "comment:delete")).toBe(true);
|
|
expect(rbac.can(undefined, "post:read")).toBe(false);
|
|
expect(defineRbac({ ed: ["post:*"] }).can({ roles: ["ed"] }, "post:write")).toBe(true); // ns wildcard
|
|
});
|
|
|
|
test("hasRole", () => {
|
|
expect(hasRole({ roles: ["a", "b"] }, "a")).toBe(true);
|
|
expect(hasRole({ roles: ["a"] }, "a", "b")).toBe(false);
|
|
});
|
|
|
|
interface User extends Record<string, unknown> {
|
|
id?: string;
|
|
roles?: string[];
|
|
tenant?: string;
|
|
}
|
|
interface Post {
|
|
authorId: string;
|
|
}
|
|
|
|
test("PBAC/ABAC: policies compose (any/all) + attribute match", async () => {
|
|
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
|
const isAdmin: Policy<User> = (u) => (u.roles ?? []).includes("admin");
|
|
const canEdit = any(ownsPost, isAdmin);
|
|
|
|
expect(await canEdit({ id: "u1" }, { authorId: "u1" })).toBe(true); // owner
|
|
expect(await canEdit({ id: "u2", roles: ["admin"] }, { authorId: "u1" })).toBe(true); // admin
|
|
expect(await canEdit({ id: "u2" }, { authorId: "u1" })).toBe(false);
|
|
|
|
const sameTenant = all(isAdmin, attr<User>("tenant", "acme"));
|
|
expect(await sameTenant({ roles: ["admin"], tenant: "acme" })).toBe(true);
|
|
expect(await sameTenant({ roles: ["admin"], tenant: "other" })).toBe(false);
|
|
});
|
|
|
|
function ctx(user?: unknown) {
|
|
const url = new URL("http://x/admin");
|
|
const c = createContext(new Request(url), url);
|
|
c.user = user;
|
|
return c;
|
|
}
|
|
|
|
test("guards: authorize / requireRole / requirePermission", async () => {
|
|
const ok = () => new Response("ok");
|
|
expect((await requireRole("admin")(ctx({ roles: ["admin"] }), ok)).status).toBe(200);
|
|
expect((await requireRole("admin")(ctx({ roles: ["viewer"] }), ok)).status).toBe(403);
|
|
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["editor"] }), ok)).status).toBe(
|
|
200,
|
|
);
|
|
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["viewer"] }), ok)).status).toBe(
|
|
403,
|
|
);
|
|
expect(
|
|
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
|
).toBe(200);
|
|
});
|
|
|
|
test("explainable decisions only include denial reasons when denied", async () => {
|
|
const policy = decision("owner", (subject: User) => subject.id === "u1");
|
|
expect(await policy({ id: "u1" })).toEqual({
|
|
allowed: true,
|
|
reason: undefined,
|
|
policy: "owner",
|
|
});
|
|
expect(await policy({ id: "u2" })).toEqual({
|
|
allowed: false,
|
|
reason: "Policy denied access",
|
|
policy: "owner",
|
|
});
|
|
});
|
|
|
|
test("owner() denies rather than matching two absent ids", async () => {
|
|
// A subject with no id, checked against a resource with no ownership key,
|
|
// must never be treated as the owner: undefined !== undefined here means
|
|
// "we don't know", not "match".
|
|
const noId: Subject = {};
|
|
const resourceWithKey = { userId: "u1" };
|
|
const resourceWithoutKey: Record<string, unknown> = { title: "t" };
|
|
const realSubject: Subject = { id: "u1" };
|
|
|
|
// Subject has no id at all.
|
|
expect((await owner()(noId, resourceWithKey)).allowed).toBe(false);
|
|
|
|
// Resource lacks the ownership key.
|
|
expect((await owner()(realSubject, resourceWithoutKey)).allowed).toBe(false);
|
|
|
|
// Both sides absent — the exact bug scenario (Object.is(undefined, undefined) === true).
|
|
expect((await owner()(noId, resourceWithoutKey)).allowed).toBe(false);
|
|
|
|
// Resource entirely absent.
|
|
expect((await owner()(realSubject, undefined)).allowed).toBe(false);
|
|
|
|
// A genuine match still allows.
|
|
expect((await owner()(realSubject, resourceWithKey)).allowed).toBe(true);
|
|
|
|
// Custom keys still work and still deny on absence.
|
|
interface CustomResource extends Record<string, unknown> {
|
|
ownerId?: string;
|
|
}
|
|
const customOwns = owner<Subject, CustomResource>("id", "ownerId");
|
|
expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true);
|
|
expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false);
|
|
});
|