fix(authz): close fail-open db store defects from review round 1
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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { authzMigrationSql } from "../src/migrations.ts";
|
||||
|
||||
/**
|
||||
* The postgres/mysql DDL is generated but never exercised against a real
|
||||
* server in this repo, so it has to be asserted statically: the id column
|
||||
* type, the `effect` CHECK constraint (an unrecognised value must not vanish
|
||||
* from both the grant and deny buckets), the MySQL binary collation (so
|
||||
* tenant "T1" cannot match "t1" and role "admin" cannot collapse with
|
||||
* "Admin"), and both UNIQUE constraints, per dialect.
|
||||
*/
|
||||
describe("authzMigrationSql", () => {
|
||||
test("sqlite: autoincrement id, no collation, both constraints", () => {
|
||||
const { up, down } = authzMigrationSql("sqlite");
|
||||
expect(up).toHaveLength(2);
|
||||
const [assignment, grant] = up;
|
||||
|
||||
expect(assignment).toContain("id INTEGER PRIMARY KEY AUTOINCREMENT");
|
||||
expect(assignment).toContain(
|
||||
"CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)",
|
||||
);
|
||||
expect(assignment).not.toContain("COLLATE");
|
||||
|
||||
expect(grant).toContain("id INTEGER PRIMARY KEY AUTOINCREMENT");
|
||||
expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))");
|
||||
expect(grant).toContain(
|
||||
"CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)",
|
||||
);
|
||||
expect(grant).not.toContain("COLLATE");
|
||||
|
||||
expect(down).toEqual([
|
||||
"DROP TABLE IF EXISTS _wrn_authz_grant",
|
||||
"DROP TABLE IF EXISTS _wrn_authz_assignment",
|
||||
]);
|
||||
});
|
||||
|
||||
test("postgres: SERIAL id, no collation, both constraints", () => {
|
||||
const { up } = authzMigrationSql("postgres");
|
||||
const [assignment, grant] = up;
|
||||
|
||||
expect(assignment).toContain("id SERIAL PRIMARY KEY");
|
||||
expect(assignment).toContain(
|
||||
"CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)",
|
||||
);
|
||||
expect(assignment).not.toContain("COLLATE");
|
||||
|
||||
expect(grant).toContain("id SERIAL PRIMARY KEY");
|
||||
expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))");
|
||||
expect(grant).toContain(
|
||||
"CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)",
|
||||
);
|
||||
expect(grant).not.toContain("COLLATE");
|
||||
});
|
||||
|
||||
test("mysql: AUTO_INCREMENT id, binary collation on identity columns, both constraints", () => {
|
||||
const { up } = authzMigrationSql("mysql");
|
||||
const [assignment, grant] = up;
|
||||
|
||||
expect(assignment).toContain("id INT AUTO_INCREMENT PRIMARY KEY");
|
||||
expect(assignment).toContain("subject_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL");
|
||||
expect(assignment).toContain("scope VARCHAR(255) COLLATE utf8mb4_bin NOT NULL DEFAULT ''");
|
||||
expect(assignment).toContain("role VARCHAR(255) COLLATE utf8mb4_bin NOT NULL");
|
||||
expect(assignment).toContain(
|
||||
"CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)",
|
||||
);
|
||||
|
||||
expect(grant).toContain("id INT AUTO_INCREMENT PRIMARY KEY");
|
||||
expect(grant).toContain("subject_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL");
|
||||
expect(grant).toContain("permission VARCHAR(255) COLLATE utf8mb4_bin NOT NULL");
|
||||
expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))");
|
||||
expect(grant).toContain(
|
||||
"CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,42 @@ export function runStoreConformance(name: string, makeStore: () => Promise<Permi
|
||||
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" });
|
||||
|
||||
Reference in New Issue
Block a user