C1/C2: grant() wrapped its delete+insert in db.tx on a shared,
unserialized sqlite connection, so a concurrent bare write from another
method (e.g. revokeRole) got swept into the open transaction and
discarded on rollback - a revoke could report success while the
privilege survived. Also broke concurrent grants on distinct keys
("cannot start a transaction within a transaction"). Replaced with
single-statement upserts (ON CONFLICT / ON DUPLICATE KEY UPDATE),
atomic without a transaction.
I1: assignRole's check-then-act SELECT lost 19/20 concurrent identical
calls to a UNIQUE violation; switched to ON CONFLICT DO NOTHING.
I2: an unrecognised `effect` value was dropped from both the grant and
deny buckets on read. Added a CHECK constraint and made anything not
literally "allow" count as a deny (fail closed).
I3: ensureAuthzTables defaulted to sqlite instead of the Db's own
dialect. I4: scopeKey now refuses an explicitly empty tenantId rather
than treating it as global (shared with the memory adapter). I5: added
migrations.test.ts asserting the generated DDL per dialect, including
MySQL's binary collation on identity columns. M1: DDL is now a
statement list instead of a blob split on a formatting-dependent
separator. M3: declared @wrnexus/db as a workspace dependency.
Extends the conformance suite with four concurrency/empty-scope tests
(23 total, up from 19) that all three adapters now pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
8.0 KiB
TypeScript
180 lines
8.0 KiB
TypeScript
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("a tenant-scoped grant does not leak into another tenant", async () => {
|
|
await store.grant("u1", "post:write", "allow", { tenantId: "t1" });
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual(["post:write"]);
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t2" })).grants).toEqual([]);
|
|
});
|
|
|
|
test("a tenant-scoped deny does not leak into another tenant", async () => {
|
|
await store.grant("u1", "post:delete", "deny", { tenantId: "t1" });
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).denies).toEqual([
|
|
"post:delete",
|
|
]);
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t2" })).denies).toEqual([]);
|
|
});
|
|
|
|
test("a global grant is visible inside every tenant", async () => {
|
|
await store.grant("u1", "post:publish", "allow");
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual([
|
|
"post:publish",
|
|
]);
|
|
});
|
|
|
|
test("revokeGrant is scope-isolated: revoking a tenant-scoped grant leaves the global grant intact", async () => {
|
|
await store.grant("u1", "post:write", "allow");
|
|
await store.grant("u1", "post:write", "allow", { tenantId: "t1" });
|
|
await store.revokeGrant("u1", "post:write", { tenantId: "t1" });
|
|
expect((await store.assignmentsFor("u1")).grants).toEqual(["post:write"]);
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual(["post:write"]);
|
|
});
|
|
|
|
test("revokeRole is scope-isolated: revoking a tenant-scoped role leaves the global role intact", async () => {
|
|
await store.assignRole("u1", "editor");
|
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
|
await store.revokeRole("u1", "editor", { tenantId: "t1" });
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
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("an explicitly empty tenantId is refused, not treated as global", async () => {
|
|
await store.assignRole("g1", "viewer");
|
|
// Otherwise a caller who controls the tenant id reaches global scope.
|
|
await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/);
|
|
await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
|
|
});
|
|
|
|
test("concurrent identical assignRole calls all resolve", async () => {
|
|
// Check-then-act loses this race; the UNIQUE constraint then rejects
|
|
// every loser even though the desired end state was already reached.
|
|
await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor")));
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
test("concurrent grants on distinct keys all resolve", async () => {
|
|
await Promise.all([
|
|
store.grant("u1", "post:read", "allow"),
|
|
store.grant("u1", "post:write", "allow"),
|
|
store.grant("u1", "post:delete", "deny"),
|
|
]);
|
|
const assignments = await store.assignmentsFor("u1");
|
|
expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]);
|
|
expect(assignments.denies).toEqual(["post:delete"]);
|
|
});
|
|
|
|
test("a concurrent write is not lost to another method's failure", async () => {
|
|
// A store that wraps one method in a transaction on a shared connection
|
|
// will roll back this unrelated write and still resolve successfully.
|
|
await store.assignRole("victim", "admin");
|
|
await Promise.all([
|
|
store.revokeRole("victim", "admin"),
|
|
store.grant("other", "post:read", "allow").catch(() => undefined),
|
|
]);
|
|
expect((await store.assignmentsFor("victim")).roles).toEqual([]);
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
|
|
test("listSubjects credits grant-only subjects", async () => {
|
|
await store.grant("g1", "post:write", "allow", { tenantId: "t1" });
|
|
expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["g1"]);
|
|
});
|
|
});
|
|
}
|