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:
@@ -1,11 +1,17 @@
|
||||
import type { Dialect } from "@wrnexus/db";
|
||||
|
||||
/**
|
||||
* DDL for the two assignment tables. `scope` holds a tenant id, or the empty
|
||||
* string for a global assignment, so the unique constraints work on every
|
||||
* dialect (NULL is not comparable in a UNIQUE index).
|
||||
* DDL for the two assignment tables, as a list of statements rather than one
|
||||
* blob: splitting a blob on a separator makes runtime correctness depend on
|
||||
* source formatting, and only the sqlite driver accepts multi-statement exec.
|
||||
*
|
||||
* `scope` holds a tenant id, or the empty string for a global assignment, so
|
||||
* the unique constraints work on every dialect (NULL is not comparable in a
|
||||
* UNIQUE index). `effect` is CHECK-constrained: an unrecognised value would
|
||||
* otherwise be dropped from both the grant and deny buckets on read, silently
|
||||
* turning a deny into a no-op.
|
||||
*/
|
||||
export function authzMigrationSql(dialect: Dialect): { up: string; down: string } {
|
||||
export function authzMigrationSql(dialect: Dialect): { up: string[]; down: string[] } {
|
||||
const id =
|
||||
dialect === "postgres"
|
||||
? "SERIAL PRIMARY KEY"
|
||||
@@ -13,31 +19,31 @@ export function authzMigrationSql(dialect: Dialect): { up: string; down: string
|
||||
? "INT AUTO_INCREMENT PRIMARY KEY"
|
||||
: "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||
const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
|
||||
const now = "CURRENT_TIMESTAMP";
|
||||
// MySQL's default collation is case- and accent-insensitive, which would let
|
||||
// tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row.
|
||||
const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : "";
|
||||
const key = `VARCHAR(255)${exact} NOT NULL`;
|
||||
|
||||
const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
|
||||
return {
|
||||
up: [
|
||||
`CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
|
||||
id ${id},
|
||||
subject_id VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(255) NOT NULL,
|
||||
granted_by VARCHAR(255),
|
||||
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
||||
subject_id ${key},
|
||||
scope ${key} DEFAULT '',
|
||||
role ${key},
|
||||
created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
|
||||
id ${id},
|
||||
subject_id VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
effect VARCHAR(16) NOT NULL,
|
||||
granted_by VARCHAR(255),
|
||||
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
||||
subject_id ${key},
|
||||
scope ${key} DEFAULT '',
|
||||
permission ${key},
|
||||
effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
|
||||
);`;
|
||||
|
||||
const down = `DROP TABLE IF EXISTS _wrn_authz_grant;
|
||||
DROP TABLE IF EXISTS _wrn_authz_assignment;`;
|
||||
|
||||
return { up, down };
|
||||
)`,
|
||||
],
|
||||
down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user