From 53130503fedf291d0f163bd8af10b0ced7c4b841 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 11 Nov 2026 12:45:00 +0100 Subject: [PATCH 1/2] fix(auth): local authz adapter picks up tuples written by other processes The index was built once at startup, so CLI-created users/grants (and revokes) did not apply to a running server until restart. Rescan data/permissions/ at most once a second. Closes #153 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + src/auth/authz-adapter-local.ts | 161 +++++++++++++++++++++----------- src/cli/users-create.ts | 6 +- tests/auth/authz_test.ts | 59 ++++++++++++ 4 files changed, 175 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4790d0a..99a1b1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ## [Unreleased] +### Fixed + +- A running server now sees permission changes made by another process. The local authz adapter built its tuple index once at startup, so a user or role granted with `dune users:create`, `users:grant-role` or `authz:sign` while `dune serve` was running could log in but was refused ("forbidden") until the server restarted, and a `users:revoke-role` did not take effect until then either. The adapter now rescans `data/permissions/` at most once a second and applies added and removed tuple files. (#153) + ## [0.36.0] — 2026-11-10 Dune 0.36 moves the admin out of core and freezes the plugin and theme contracts as two small diff --git a/src/auth/authz-adapter-local.ts b/src/auth/authz-adapter-local.ts index a0872e28..a414b696 100644 --- a/src/auth/authz-adapter-local.ts +++ b/src/auth/authz-adapter-local.ts @@ -4,9 +4,10 @@ * Permission tuples are stored as individual JSON files: * {dataDir}/permissions/{uuid}.json * - * An in-memory index is built at construction time by scanning existing files. - * On restart the index is rebuilt from disk — consistent with Dune's in-memory - * rebuild pattern (search index, content index, etc.). + * An in-memory index is built lazily on first use and re-synchronised with the + * directory at most once per second, so tuples written or deleted by another + * process (the `dune users:*` CLI against a running server) take effect without + * a restart. * * Beyond the flat `tuples` map (id → tuple), three composite-key indexes — * by subject+relation, by object+relation, and by subject+object+relation — @@ -146,8 +147,11 @@ export class AuthzLocalAdapter { dataDir: string; hmacKey?: CryptoKey | null; strictHmac?: boolean; + /** Min ms between directory rescans (default 1000; 0 = every call). */ + refreshIntervalMs?: number; }, ) { + this.refreshIntervalMs = config.refreshIntervalMs ?? 1000; this.storage = config.storage; this.permissionsDir = `${config.dataDir}/permissions`; this.hmacKey = config.hmacKey ?? null; @@ -259,68 +263,116 @@ export class AuthzLocalAdapter { ); } - // ── Lazy index load ───────────────────────────────────────────────────────── + // ── Index load / refresh ──────────────────────────────────────────────────── + /** Minimum interval between directory rescans (ms). */ + private readonly refreshIntervalMs: number; + /** Tuple file name → tuple id, for every file currently in the index. */ + private readonly fileToId: Map = new Map(); + private lastRefresh = 0; + + /** + * Bring the in-memory index in line with data/permissions/. + * + * The first call loads everything. Later calls (throttled to once per + * `refreshIntervalMs`, default 1000) rescan the directory so tuples written or deleted by + * another process — `dune users:create`, `users:grant-role`, `authz:sign` + * run against a live server — take effect without a restart. Tuple files are + * immutable once written, so only added/removed file names are processed; + * files that failed verification are retried on each rescan so signing them + * later picks them up. + */ private ensureLoaded(): Promise { - if (this.loaded) return Promise.resolve(); if (this.loadPromise !== null) return this.loadPromise; + if ( + this.loaded && + Date.now() - this.lastRefresh < this.refreshIntervalMs + ) { + return Promise.resolve(); + } - this.loadPromise = (async () => { + const run = (async () => { try { - const entries = await this.storage.list(this.permissionsDir); - await Promise.all( - entries - .filter((e) => e.isFile && e.name.endsWith(".json")) - .map(async (e) => { - try { - const raw = await this.storage.read(e.path); - const tuple = JSON.parse( - new TextDecoder().decode(raw), - ) as SignedTuple; - if (!tuple.id) return; - - // HMAC verification — only when a key is configured - if (this.hmacKey) { - const result = await verifyTuple(tuple, this.hmacKey); - if (result === "invalid") { - logger.warn("authz.tuple.invalid_hmac", { - tupleId: tuple.id, - file: e.name, - reason: - "invalid HMAC — file may have been tampered with. Tuple NOT loaded.", - }); - return; - } - if (result === "missing" && this.strictHmac) { - logger.warn("authz.tuple.unsigned_strict", { - tupleId: tuple.id, - file: e.name, - reason: - "unsigned but strict HMAC mode is enabled. Tuple NOT loaded. Run `dune authz:sign` to sign it.", - }); - return; - } - // result === "missing" (non-strict): unsigned file, accepted during migration - } - - // Strip the hmac field before storing in the in-memory index - const { hmac: _hmac, ...stored } = tuple; - const storedTuple = stored as PolizyStoredTuple; - this.tuples.set(storedTuple.id, storedTuple); - this.indexTuple(storedTuple); - } catch { - // Skip corrupt files - } - }), - ); + await this.scan(); } catch { // Permissions directory doesn't exist yet — that's fine; writes will create it } finally { this.loaded = true; + this.lastRefresh = Date.now(); + this.loadPromise = null; } })(); + this.loadPromise = run; + return run; + } - return this.loadPromise; + private async scan(): Promise { + // Only files known before the listing are removal candidates: a tuple + // written in-process while list() was pending is not in the listing yet. + const known = new Set(this.fileToId.keys()); + const entries = await this.storage.list(this.permissionsDir); + const files = entries.filter((e) => e.isFile && e.name.endsWith(".json")); + const present = new Set(files.map((e) => e.name)); + + // Removed by another process + for (const [name, id] of [...this.fileToId]) { + if (present.has(name) || !known.has(name)) continue; + const tuple = this.tuples.get(id); + if (tuple) { + this.tuples.delete(id); + this.unindexTuple(tuple); + } + this.fileToId.delete(name); + } + + // New (or previously rejected) files + await Promise.all( + files + .filter((e) => !this.fileToId.has(e.name)) + .map((e) => this.loadFile(e)), + ); + } + + private async loadFile(e: { name: string; path: string }): Promise { + try { + const raw = await this.storage.read(e.path); + const tuple = JSON.parse(new TextDecoder().decode(raw)) as SignedTuple; + if (!tuple.id) return; + + // HMAC verification — only when a key is configured + if (this.hmacKey) { + const result = await verifyTuple(tuple, this.hmacKey); + if (result === "invalid") { + logger.warn("authz.tuple.invalid_hmac", { + tupleId: tuple.id, + file: e.name, + reason: + "invalid HMAC — file may have been tampered with. Tuple NOT loaded.", + }); + return; + } + if (result === "missing" && this.strictHmac) { + logger.warn("authz.tuple.unsigned_strict", { + tupleId: tuple.id, + file: e.name, + reason: + "unsigned but strict HMAC mode is enabled. Tuple NOT loaded. Run `dune authz:sign` to sign it.", + }); + return; + } + // result === "missing" (non-strict): unsigned file, accepted during migration + } + + // Strip the hmac field before storing in the in-memory index + const { hmac: _hmac, ...stored } = tuple; + const storedTuple = stored as PolizyStoredTuple; + if (this.tuples.has(storedTuple.id)) return; // written in-process meanwhile + this.tuples.set(storedTuple.id, storedTuple); + this.indexTuple(storedTuple); + this.fileToId.set(e.name, storedTuple.id); + } catch { + // Skip corrupt files + } } // ── StorageAdapter methods ────────────────────────────────────────────────── @@ -346,6 +398,7 @@ export class AuthzLocalAdapter { ); this.tuples.set(id, stored); this.indexTuple(stored); + this.fileToId.set(`${id}.json`, id); results.push(stored); } return results; @@ -365,6 +418,7 @@ export class AuthzLocalAdapter { // reloaded — stale permissions are the safe-fail direction here. this.tuples.delete(id); this.unindexTuple(tuple); + this.fileToId.delete(`${id}.json`); count++; } catch (err) { // File already gone is fine — remove from in-memory index so the two @@ -377,6 +431,7 @@ export class AuthzLocalAdapter { if (isGone) { this.tuples.delete(id); this.unindexTuple(tuple); + this.fileToId.delete(`${id}.json`); count++; } else { logger.warn("authz.tuple.delete_failed", { diff --git a/src/cli/users-create.ts b/src/cli/users-create.ts index a94717f4..e8420239 100644 --- a/src/cli/users-create.ts +++ b/src/cli/users-create.ts @@ -26,8 +26,10 @@ * Reuses the same UserStore primitive users-role.ts's grant/revoke-role * commands do, and syncs the app:site authz tuple the same way when an * admin-tier role (admin/editor/author) is included, so a pre-granted - * admin-tier role is immediately effective — not just present in roles[] - * but inert until the user's first login re-syncs it. + * admin-tier role is effective at once — not just present in roles[] but + * inert until the user's first login re-syncs it. A running `dune serve` picks + * the new tuples up on its next permission check (the local authz adapter + * rescans data/permissions/ about once a second); no restart is needed. * * With --password-prompt (or --stdin / --generate) it instead creates a * *local-password* account (`provider: "local"`, `--username`, default the diff --git a/tests/auth/authz_test.ts b/tests/auth/authz_test.ts index ba660903..b7810a69 100644 --- a/tests/auth/authz_test.ts +++ b/tests/auth/authz_test.ts @@ -914,3 +914,62 @@ Deno.test("external-jwt provisioning: a different user's tuples don't affect ano false, ); }); + +// ── Cross-process visibility (dune#153) ────────────────────────────────────── + +Deno.test("AuthzLocalAdapter: picks up tuples written and deleted by another process", async () => { + const storage = makeStorage(); + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + refreshIntervalMs: 0, + }); + // Server starts with an empty index. + assertEquals(await server.size(), 0); + + // A second adapter on the same files stands in for the CLI process. + const cli = new AuthzLocalAdapter({ storage, dataDir: "data" }); + const subject = { type: "user", id: "u1" }; + const object = { type: "group", id: "admin" }; + await cli.write([{ subject, relation: "member", object }]); + + assertEquals(await server.hasTuple(subject, "member", object), true); + assertEquals((await server.findObjects(subject, "member")).length, 1); + + await cli.delete({ who: subject }); + assertEquals(await server.hasTuple(subject, "member", object), false); + assertEquals(await server.size(), 0); +}); + +Deno.test("AuthzLocalAdapter: rescans are throttled", async () => { + const storage = makeStorage(); + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + refreshIntervalMs: 60_000, + }); + await server.size(); + const cli = new AuthzLocalAdapter({ storage, dataDir: "data" }); + await cli.write([{ + subject: { type: "user", id: "u1" }, + relation: "member", + object: { type: "group", id: "admin" }, + }]); + assertEquals(await server.size(), 0); +}); + +Deno.test("AuthzLocalAdapter: in-process writes survive a rescan", async () => { + const storage = makeStorage(); + const a = new AuthzLocalAdapter({ + storage, + dataDir: "data", + refreshIntervalMs: 0, + }); + const [t] = await a.write([{ + subject: { type: "user", id: "u1" }, + relation: "member", + object: { type: "group", id: "admin" }, + }]); + assertEquals(await a.size(), 1); + assertEquals((await a.findTuples({})).map((x) => x.id), [t.id]); +}); From 8ecfecb219dff5f87b15e4dfdcfb2ce4c717ac42 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 09:26:49 +0100 Subject: [PATCH 2/2] fix(authz): log rejected tuple files once, handle a vanished permissions dir Files that fail HMAC verification are still retried on every rescan so authz:sign can pick them up, but are only logged again when their content changes. A missing data/permissions/ is treated as all files gone. Adds tests for sign-later, invalid HMAC, log-once and the missing directory, and documents the in-process delete race. Co-Authored-By: Claude Sonnet 5.5 --- src/auth/authz-adapter-local.ts | 32 ++++++- tests/auth/authz_test.ts | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/auth/authz-adapter-local.ts b/src/auth/authz-adapter-local.ts index a414b696..38cb3536 100644 --- a/src/auth/authz-adapter-local.ts +++ b/src/auth/authz-adapter-local.ts @@ -269,6 +269,12 @@ export class AuthzLocalAdapter { private readonly refreshIntervalMs: number; /** Tuple file name → tuple id, for every file currently in the index. */ private readonly fileToId: Map = new Map(); + /** + * Files that failed verification → the raw content that was rejected. They + * are retried on every rescan (so `authz:sign` can pick them up) but only + * logged again if their content changes. + */ + private readonly rejected: Map = new Map(); private lastRefresh = 0; /** @@ -309,10 +315,26 @@ export class AuthzLocalAdapter { private async scan(): Promise { // Only files known before the listing are removal candidates: a tuple // written in-process while list() was pending is not in the listing yet. + // + // Known race: an in-process delete() that lands while this scan's list() + // is pending can see the just-revoked tuple's file in the (stale) listing + // and re-add it. The next rescan (<= refreshIntervalMs later) removes it + // again, since the file is gone from disk by then. const known = new Set(this.fileToId.keys()); - const entries = await this.storage.list(this.permissionsDir); + let entries: Awaited>; + try { + entries = await this.storage.list(this.permissionsDir); + } catch (err) { + // A vanished directory means every tuple file is gone. Confirmed with + // exists() so a transient list() error doesn't wipe the index. + if (await this.storage.exists(this.permissionsDir)) throw err; + entries = []; + } const files = entries.filter((e) => e.isFile && e.name.endsWith(".json")); const present = new Set(files.map((e) => e.name)); + for (const name of this.rejected.keys()) { + if (!present.has(name)) this.rejected.delete(name); + } // Removed by another process for (const [name, id] of [...this.fileToId]) { @@ -336,13 +358,16 @@ export class AuthzLocalAdapter { private async loadFile(e: { name: string; path: string }): Promise { try { const raw = await this.storage.read(e.path); - const tuple = JSON.parse(new TextDecoder().decode(raw)) as SignedTuple; + const text = new TextDecoder().decode(raw); + const tuple = JSON.parse(text) as SignedTuple; if (!tuple.id) return; // HMAC verification — only when a key is configured if (this.hmacKey) { const result = await verifyTuple(tuple, this.hmacKey); if (result === "invalid") { + if (this.rejected.get(e.name) === text) return; + this.rejected.set(e.name, text); logger.warn("authz.tuple.invalid_hmac", { tupleId: tuple.id, file: e.name, @@ -352,6 +377,8 @@ export class AuthzLocalAdapter { return; } if (result === "missing" && this.strictHmac) { + if (this.rejected.get(e.name) === text) return; + this.rejected.set(e.name, text); logger.warn("authz.tuple.unsigned_strict", { tupleId: tuple.id, file: e.name, @@ -370,6 +397,7 @@ export class AuthzLocalAdapter { this.tuples.set(storedTuple.id, storedTuple); this.indexTuple(storedTuple); this.fileToId.set(e.name, storedTuple.id); + this.rejected.delete(e.name); } catch { // Skip corrupt files } diff --git a/tests/auth/authz_test.ts b/tests/auth/authz_test.ts index b7810a69..9c9bba79 100644 --- a/tests/auth/authz_test.ts +++ b/tests/auth/authz_test.ts @@ -973,3 +973,149 @@ Deno.test("AuthzLocalAdapter: in-process writes survive a rescan", async () => { assertEquals(await a.size(), 1); assertEquals((await a.findTuples({})).map((x) => x.id), [t.id]); }); + +// ── Rejected tuple files across rescans (dune#153 review) ──────────────────── + +/** Run fn while capturing logger output (the default logger writes to console.error). */ +async function captureLog(fn: () => Promise): Promise { + const lines: string[] = []; + const orig = console.error; + console.error = (...args: unknown[]) => lines.push(args.join(" ")); + try { + await fn(); + } finally { + console.error = orig; + } + return lines; +} + +Deno.test("AuthzLocalAdapter: unsigned file under strict HMAC loads on a later rescan once signed", async () => { + const { signTuple } = await import("../../src/auth/authz-hmac.ts"); + const key = await makeHmacKey(); + const storage = makeStorage(); + const base = { + id: "t-late", + subject: { type: "user", id: "alice" }, + relation: "member", + object: { type: "group", id: "admins" }, + }; + await storage.write("data/permissions/t-late.json", JSON.stringify(base)); + + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + hmacKey: key, + strictHmac: true, + refreshIntervalMs: 0, + }); + assertEquals(await server.size(), 0); + + // `dune authz:sign` rewrites the file with an hmac. + const hmac = await signTuple(base, key); + await storage.write( + "data/permissions/t-late.json", + JSON.stringify({ ...base, hmac }), + ); + assertEquals(await server.size(), 1); + assertEquals( + await server.hasTuple(base.subject, "member", base.object), + true, + ); +}); + +Deno.test("AuthzLocalAdapter: file with an invalid HMAC never loads, and is logged once", async () => { + const key = await makeHmacKey(); + const storage = makeStorage(); + const tampered = { + id: "t-bad", + subject: { type: "user", id: "mallory" }, + relation: "member", + object: { type: "group", id: "admins" }, + hmac: "00".repeat(32), + }; + await storage.write("data/permissions/t-bad.json", JSON.stringify(tampered)); + + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + hmacKey: key, + strictHmac: true, + refreshIntervalMs: 0, + }); + const lines = await captureLog(async () => { + for (let i = 0; i < 4; i++) { + assertEquals(await server.size(), 0); + } + }); + assertEquals( + lines.filter((l) => l.includes("authz.tuple.invalid_hmac")).length, + 1, + ); + assertEquals( + await server.hasTuple(tampered.subject, "member", tampered.object), + false, + ); + + // Changing the file's content re-logs it. + await storage.write( + "data/permissions/t-bad.json", + JSON.stringify({ ...tampered, hmac: "11".repeat(32) }), + ); + const again = await captureLog(async () => { + await server.size(); + }); + assertEquals( + again.filter((l) => l.includes("authz.tuple.invalid_hmac")).length, + 1, + ); +}); + +Deno.test("AuthzLocalAdapter: unsigned strict-mode file is logged once across rescans", async () => { + const key = await makeHmacKey(); + const storage = makeStorage(); + await storage.write( + "data/permissions/t-u.json", + JSON.stringify({ + id: "t-u", + subject: { type: "user", id: "m" }, + relation: "member", + object: { type: "group", id: "g" }, + }), + ); + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + hmacKey: key, + strictHmac: true, + refreshIntervalMs: 0, + }); + const lines = await captureLog(async () => { + for (let i = 0; i < 3; i++) await server.size(); + }); + assertEquals( + lines.filter((l) => l.includes("authz.tuple.unsigned_strict")).length, + 1, + ); +}); + +Deno.test("AuthzLocalAdapter: removing the permissions directory drops cached grants", async () => { + const storage = makeStorage(); + const server = new AuthzLocalAdapter({ + storage, + dataDir: "data", + refreshIntervalMs: 0, + }); + await server.write([{ + subject: { type: "user", id: "u1" }, + relation: "member", + object: { type: "group", id: "admin" }, + }]); + assertEquals(await server.size(), 1); + + // Directory vanishes: list() throws, exists() is false. + for (const e of await storage.list("data/permissions")) { + await storage.delete(e.path); + } + storage.list = () => Promise.reject(new Error("ENOENT")); + assertEquals(await server.size(), 0); +});