Skip to content

Commit c7a3234

Browse files
perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice (#22441)
Refs objectstack-ai/cloud#2634 (item 2: the pre-handler lever) Clause-②: no ## What this changes An authenticated request that resolves identity through `resolveAuthzContext` with a better-auth session read resolved the caller's grants **twice**, with the same arguments and no write in between: 1. **Inside the session read.** `resolveAuthzContext` calls the transport's `getSession`; against plugin-auth that is better-auth's `getSession`, whose `customSession` hook resolves the grants for the payload's `positions[]` / `isPlatformAdmin` (`packages/plugins/plugin-auth/src/auth-manager.ts:4079` on main). 2. **Step 2 of the resolver.** `resolveAuthzContext` then calls `resolveUserAuthzGrants` again for the request envelope (`packages/core/src/security/resolve-authz-context.ts:428` on main). Both call sites were confirmed on an instrumented request (async stacks below), not only by reading. Each resolution is 8 tenant-DB reads for a caller in an organization: `sys_user`, `sys_member` (own and peers), `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set` and `sys_permission_set`. The fix is a **request-scoped grants memo** (`packages/core/src/security/request-grants-memo.ts`): - `resolveAuthzContext` runs its whole body, the `getSession` call included, inside an `AsyncLocalStorage` scope. The scope is **closed when the call settles**. No envelope survives the request, and a continuation the request started reads afresh once it has settled. - `resolveUserAuthzGrants` consults the scope first. A completed resolution with the **same arguments** (user, tenant, seed email, seed permissions, spelled as the cross-request cache spells its key, with the engine as the outer key) is served as a clone. It is served only while a fresh read would agree with it: - **No write has started, been executed at the driver, or is in flight on the engine since that resolution opened.** Two signals are read at the open (before its first read) and again at the lookup: - the engine's write epoch, which the engine bumps when a write *starts*, and for non-write reasons; - a **write observer**: a middleware the memo registers on first sight of an engine. It counts each write that enters it, and, in a `finally` around `next()`, each write whose driver step has settled. The driver step runs inside that `next()`, so a write cannot be executed at the driver without moving the counters. An entry is served only when the epoch and both counters read what they read at the open and nothing is inside the observer. The observer sees statement execution, not commit visibility: a write inside an `engine.transaction()` becomes visible at the driver COMMIT, outside every chain (see Acceptance notes). - **The reading clock lies in `[resolvedAt, nextValidityBoundary)`.** - The memo **declines** in four cases: an engine without the write-epoch seam or `registerMiddleware`, a `bypassGrantsCache` caller, a resolution that threw (never stored), and any call outside a `resolveAuthzContext` scope. The scope that registers an engine's observer stores nothing for that engine, so that request reads twice. An engine without the epoch seam gets no observer at all. - plugin-auth's hook now passes the session's email as `seedEmail`, spelled exactly as `resolveAuthzContext` spells it for the same session, so the two calls ask for the same resolution. That seed reaches only the envelope's `email`, which the hook does not read. The hook's `positions[]` / `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed (core §6b-config). **Why a memo-side observer, not a second engine-side bump.** - **The engine route** would bump `write` again in a `finally` after `executor()`. That changes the pinned one-bump-per-write seam: `objectql/src/write-epoch.test.ts` pins "insert, update and delete each advance it exactly once". It would also double the cluster `authz.invalidated` hints, because `authz-invalidation-bridge.ts` publishes every non-remote bump. And it would still serve an entry while a write had committed at the driver but not yet settled. - **The observer** is core-only, follows the grants cache's own seam-1 pattern, and its `started === completed` check covers that last window. Every other `writeEpoch` reader is untouched. **Where this landed, and why here.** The card's suggested file surface was the downstream agent route. Measurement put both resolutions in this repo: the dispatcher's `resolveExecutionContext` → core `resolveAuthzContext` → plugin-auth's hook, all before any route handler runs. Any downstream-side change would have been a workaround over a framework duplicate. So the fix sits at the producer, and the downstream repo gets it with its next framework pin bump. **Other doors.** Every caller of `resolveAuthzContext` with a better-auth `getSession` gets the same saving through the same function. That covers the runtime dispatcher (agent chat, Ask, data routes, metadata, everything behind `resolveRequestScope`), the REST server, the settings, storage, datasource-admin, sharing and marketplace-install routes, and the downstream env-settings routes. **Where the saving does not apply** (the request reads twice, as before; always the safe direction): - a request that meets a concurrent write on its engine; - a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; - the first `resolveAuthzContext` call on an engine. ## Equivalence: the same decision, per caller class The security floor: the grant set a request is authorised with must be the same decision as before, the dedupe is scoped to one request, and no check is skipped or loosened. - **Per caller class** (`resolve-authz-context.request-grants-memo.test.ts`, `CALLER_CLASSES` at :285): platform administrator via the unscoped `admin_full_access` grant (single posture) and via the declared administrator email (isolated posture), organization owner, admin and member, a non-member whose claimed organization is dropped (walled) or stands (single), an **API-key principal** with scopes and a stamped organization (`x-api-key`), and an anonymous request. For each class, the envelope a request resolves with the memo serving step 2 is **deep-equal** to the envelope step 2 resolves on its own, and the read multiset equals one resolution's. Each class also asserts its own expected posture, positions or scopes, so two equally wrong envelopes cannot pass. - **With the real hook** (`session-grants-resolved-once.test.ts`): a real better-auth `getSession` over the shared memory engine double, which the test drives through the write epoch and a middleware chain the way the engine runs them. Org member, owner, platform operator, removed member with a stale claim (dropped exactly as before), and anonymous each resolve to an envelope deep-equal to the same request with the memo declined (epoch seam removed, which is the pre-memo path), with one resolution's grant reads instead of two. - **Writes in flight.** - :589 opens the hook's resolution after a revocation has bumped the epoch but before it lands, then lands it before step 2. Step 2 reads afresh and the envelope equals the post-write baseline. - :622 holds the write in flight across step 2; step 2 reads afresh. - :638 covers a write that starts and lands in between. - :660 covers a non-write epoch bump. - :677 covers a write that starts during the first resolution's reads. - **Isolation.** - :457: two interleaved requests from different callers, with both session reads committed before either step 2, keep their own grants. - :492: a revocation between two requests with **no** epoch bump is seen by the second request. - :510: a continuation started inside a request reads afresh after it settles. - :535: a session read against a **second engine** serves nothing to the first engine's step 2. - :552: a **nested** `resolveAuthzContext` serves nothing to the outer step 2. - **Clock.** A step-2 clock one hour after the hook's, inside the validity window, is served: 8 reads, and the envelope equals the baseline at T0 + 1 h (:691). A boundary between the two clocks, or a step-2 clock earlier than the resolution, reads afresh (:701). - **No aliasing**: the session payload's arrays and the envelope's are distinct objects (:753). ## Measurement: round trips before the handler, hosted composition Rig: the downstream hosted HTTP composition (artifact kernel factory with the hosted forced requires, kernel manager, cloud kernel resolver, the objectos host slate, REST and dispatcher over Hono). The tenant driver is a `TursoDriver` on the **remote** face, over a `@libsql/client` wrapped so that every `execute` / `batch` / `transaction` op counts as one round trip. The handler entry is a wrapper on the env kernel's agent-chat route; the model is a memory adapter. The framework is at the downstream pin `56bf27af`, unpatched for before, with this PR's code files at `870b4297` applied for after; the downstream checkout is `b7d13034`. Every request is `POST /api/v1/ai/agents/build/chat`, stream on, status 200. | request | before | after | |---|---|---| | T1 founder, first AI turn on the kernel | 31 | 31 | | T2 founder, warm | **23** | **15** | | T3 founder, warm (+2 `sys_job_queue`, background) | 25 | 17 | | T4 member, warm | 23 | 15 | | T5 member, warm | 23 | 15 | - **Warm T2 per table, before:** `sys_user` 3, `sys_member` 4, and 2 each for the five grant-only tables. - **Warm T2 per table, after:** `sys_user` 2, `sys_member` 2, and 1 each for the five grant-only tables. - **Unchanged:** `ai_messages` 1, `sys_session` 2, `sys_jwks` 2, `sys_setting` 1. - So 23 − 16 + 8 = 15. - **T1 is unchanged by design.** That request registers the write observer, and its session read writes the JWT signing key. - **The real `ObjectQL` engine** took the observer through `registerMiddleware`, and the warm path met no concurrent write. - **Call sites, from the async stacks of the instrumented T2.** - Before, `sys_user_position` was read twice. The first read came from `tryFind` ← `resolveUserAuthzGrants` ← `auth-manager.ts:4079` ← better-auth `custom-session` ← `resolve-execution-context.ts:160` (`getSession`) ← `resolve-authz-context.ts:401` (`resolveAuthzContext`). The second came from `tryFind` ← `resolveUserAuthzGrants` ← `resolve-authz-context.ts:428`, with the same dispatcher frames below (`http-dispatcher.ts:1008` / `:619` / `:2640`). - After, the hook's read is the only one. - Line numbers are at `56bf27af`. - **Wall clock: not a staging reading.** The rig has no network, so its millisecond delta mostly measures the probe's own per-round-trip cost. On a hosted plane the saving is 8 round trips × that plane's tenant-DB RTT, which this rig cannot measure. ## Tests Final head `da29c616` (round 2 changed comment and changeset text only; the code and tests are those of `870b4297`). - **`@objectstack/core`** - build + `typecheck` exit 0; the test layer holds its debt at 4 files / 4 errors, unchanged; - local suite: 84 files, 2,258 passed, the memo pin's 26 tests included; - `test:repo`: 3 files, 48 passed. - **`@objectstack/plugin-auth`** - build + `typecheck` exit 0; debt 10 files / 94 errors, unchanged; - suite: 133 files, 2,689 passed, 10 skipped. - **Consumers of `resolveAuthzContext`** (at `606c3340`, whose code equals the head's; `870b4297` reorders assertions in one core test only): - `@objectstack/runtime` full suite: 346 files, 5,579 passed, 19 skipped; - the files that call `resolveAuthzContext` / `resolveExecutionContext`: plugin-security 2 files, 126 passed; plugin-sharing 1, 28; service-datasource 1, 29; service-storage 1, 26; rest 7 files, 216 passed. - **Ablations.** Each ran through `scripts/ablation-replace.mjs` in wrap mode against the committed memo file (HEAD blob `9e08f71d`). Every leg's anchor went 1 → 0, the blob changed, the restore blob equals the HEAD blob, and `git diff HEAD` was empty. Over the 26 memo pins: - **A6, landing guard reverted to the epoch-only guard of `0b07e024`:** 2 red / 24 green. :589 reds with `expected [ 'org_member', 'auditor', … ] to not include 'auditor'`: the request is authorised with the revoked position, which is the review's interleaving. :622 reds with `expected 8 to be 16`. With the guard: green. - **A0, memo off:** 15 red / 11 green, as predicted. The red ones are the 7 session classes' read counts, the count pin, observer registration, interleaved, nothing-outlives, nested, the positive clock, bypass and clones. - **A1, key dropped (constant key):** 1 red, the walled non-member: its dropped-claim re-resolution is served the claimed organization's envelope. - **A2, entries shared across requests and the scope never closed:** 3 red (nothing-outlives, continuation, nested). - **A3, A1 + A2:** 5 red, the cross-caller test among them. - **A4, epoch comparison dropped:** 1 red (the non-write bump). - **A5, clock window dropped:** 1 red (the boundary). - **A7, registering scope stores anyway:** 1 red (observer registration). - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 68 families at `870b4297`. All 68 exited 0, recorded per command with its exit code and reconciled with `--ran`: `68 run, 0 NOT-MEASURED (a DERIVED zero)`. - `check:dual-build-cjs-loads` first exited 3 (no `dist/` yet in this worktree). It exited 0 after `check:type-check-debt`'s re-measure had built the packages. - The derivation warns that the tree is 9 commits behind `origin/main` `da159f74`, with `ci.yml`, `partition-test-shards.mjs` and `sdui-manifest.record.json` changed there. No upstream commit touches `core`, `plugin-auth` or `objectql`. - **Round 2, text-only, at `da29c616`.** - The diff from `870b4297` touches only `request-grants-memo.ts` (+17/−2, every added and removed line inside the module's JSDoc block) and the changeset (1 line). The two versions of the `.ts` file produce byte-identical comment-stripped `transpileModule` output (sha256 prefix `9c90b60a9eba1fdd` both). - `@objectstack/core` typecheck exit 0; the memo pin, 26 passed. - All exit 0: `check:nul-bytes`, `check-comment-mask-adoption` (and its `--self-test`), `check-comment-mask-corpus` (8,517 files, 0 disagree), `check-changeset-no-major`, `check-empty-changeset`, `check-adr-0087-registration`, `check:doc-authoring` and `check:issue-citations`. - Derived gate list unchanged (68). - **Repo-wide lint:** CI's. ## Acceptance notes - **What the observer cannot see, stated in the module doc.** The engine snapshots its middleware list when a write starts, so a write that began before the observer was registered never passes it. The registering request stores nothing, which leaves one case open: a write that began before an engine's first `resolveAuthzContext`, still in flight when a later request's resolution opens, landing before that request's step 2. Writes from another process are read as of the first resolution, a few milliseconds earlier than the second used to read them. Closing the first case needs the engine to report in-flight writes, which is a public-surface change to `WriteEpoch` and out of this patch. - **Transactional COMMIT, stated in the module doc.** A grant-table write executed on an `engine.transaction()` passes the observer per statement, so the counters move and balance. Its rows become visible to other connections only at the driver COMMIT, which runs outside every middleware chain. If that COMMIT lands between the first resolution and step 2 (one commit round trip after the transaction's last statement), step 2 is served the pre-commit envelope. - Reachable on driver-sql deployments: SCIM through the better-auth adapter, and REST `/batch`. - Not reachable on the Turso remote face, which declares `transactionsUnsupported` and opens no transaction. - The consequence is the out-of-process one: that request is authorised as of its first resolution, and the next request reads fresh. - The optional engine-side closure, an objectql epoch bump after an owned transaction's commit, is out of scope here. - **Other duplicates.** A few session reads still resolve grants outside `resolveAuthzContext`, so this memo does not reach them; this is a code reading, not measured on the agent-chat path: - `HttpDispatcher.enforceAuthGate` re-reads the session when an auth-gate feature is active; - `enforceProjectMembership` re-reads it when membership enforcement is on; - the current-user permissions endpoint (`plugin-hono-server/src/current-user-endpoints.ts:430`) reads a session and then resolves grants with different seeds. None of them ran on the measured hosted request. No owner. - **Read twice in the handler.** The localization setting is still read twice per AI request, once by the execution context and once by the agent route's turn time zone. That is inside the handler and already on the downstream card. No owner here. --- _Generated by [Claude Code](https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5919483 commit c7a3234

6 files changed

Lines changed: 1521 additions & 5 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice
7+
8+
Clause-②: no
9+
10+
`resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization.
11+
12+
`resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged:
13+
14+
- The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`.
15+
- An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely.
16+
- `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed.
17+
18+
Where the saving does not apply, the request reads the grants twice, as before — always the safe direction:
19+
20+
- a request that meets a concurrent write on its engine;
21+
- a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session;
22+
- the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it.
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The request-scoped grants memo — one {@link resolveAuthzContext} call
5+
* resolves a principal's grants ONCE, however many readers inside it ask.
6+
*
7+
* ## Why a resolution is asked for twice inside one request
8+
*
9+
* `resolveAuthzContext` learns WHO the caller is by calling the transport's
10+
* `getSession`. Against `@objectstack/plugin-auth` that call runs better-auth's
11+
* `getSession` endpoint, whose `customSession` hook builds the session
12+
* payload's `positions[]` / `isPlatformAdmin` by asking
13+
* `resolveUserAuthzGrants` — the one authority, on purpose (a second
14+
* derivation there is the drift `resolve-authz-context.ts` exists to end).
15+
* `resolveAuthzContext` then resolves the same principal's grants AGAIN, with
16+
* the same arguments, to build the request's envelope. Nothing is written
17+
* between the two on a healthy request, so the second resolution re-issues
18+
* every grant read of the first and gets the same rows back. Measured
19+
* downstream on a hosted composition: 16 of the 23 serial tenant-DB round
20+
* trips an authenticated request makes before its handler were these two
21+
* resolutions, eight each.
22+
*
23+
* ## The scope — one `resolveAuthzContext` call, never longer
24+
*
25+
* `resolveAuthzContext` opens a scope ({@link withRequestGrantsMemo}) around its
26+
* whole body, the `getSession` call included, so the hook's resolution and the
27+
* resolver's own run inside the SAME scope; `resolveUserAuthzGrants` consults
28+
* it ({@link openRequestGrantsMemo}). The scope is an `AsyncLocalStorage`
29+
* store, so two requests interleaving across their awaits each see only their
30+
* own, and it is CLOSED when `resolveAuthzContext` settles: a continuation that
31+
* outlives the resolution (a background task the session read started) reads
32+
* nothing and stores nothing. No envelope survives the request — ⛔ this is not
33+
* a cache and must not become one; the cross-request cache is
34+
* `resolve-user-grants-cache.ts`, behind its own ruled default-off switch. The
35+
* only module-level state is the per-engine write observer below: two
36+
* counters per engine, no grant data.
37+
*
38+
* A host whose async context does not propagate (WebContainer's
39+
* `node:async_hooks`) simply finds no scope, and every resolution is fresh —
40+
* the behaviour before this module existed.
41+
*
42+
* ## What an entry answers for — it is served only when a fresh read would agree
43+
*
44+
* - **Same call.** The key is the `resolveUserAuthzGrants` arguments that
45+
* shape the answer — user, tenant, seed email, seed permissions — spelled as
46+
* the cross-request cache spells them, so a different organization (the
47+
* session arm's claim-drop re-resolution), a different seed or a different
48+
* user is a different entry, and the engine (`ql`) is the outer key. Seeds
49+
* are part of the key because they are part of the answer (see
50+
* `resolve-user-grants-cache.ts`, "Keying").
51+
* - **No write has landed, or is landing, since the resolution opened.** Two
52+
* signals, both read when the resolution OPENS (before its first read) and
53+
* again when the entry is looked up:
54+
* 1. The engine's write epoch. The engine bumps it when a write STARTS,
55+
* ahead of its middleware chain (and for non-write reasons: a declared
56+
* permission set, a peer's hint). It says nothing about when a write
57+
* LANDS, so on its own it would serve an entry read while a write that
58+
* had already bumped was still in flight, after that write landed.
59+
* 2. The engine write observer: a middleware this module registers on
60+
* first sight of an engine, counting every write that enters it and,
61+
* in a `finally` around `next()`, every write whose driver step has
62+
* settled. The driver step runs INSIDE that `next()`, so a write cannot
63+
* be EXECUTED at the driver without first moving `started`, and cannot
64+
* finish executing without moving `completed`. The observer sees
65+
* statement execution, not commit visibility: on an
66+
* `engine.transaction()` the rows become visible to other connections
67+
* only at the driver COMMIT, outside every chain (residuals below).
68+
* An entry is served only when the epoch, `started` and `completed` all read
69+
* what they read at the open AND no write is inside the observer
70+
* (`started === completed`). So a write that started before the open and
71+
* lands after it, one that starts after it, and one still in flight at the
72+
* lookup all make step 2 read afresh. A `ql` without the epoch seam or
73+
* without `registerMiddleware` declines entirely: an answer whose staleness
74+
* cannot be observed is not served, not even for milliseconds.
75+
* - **No validity boundary in between.** An ADR-0091 window flips with no
76+
* write at all, so an entry is served only to a call whose clock lies in
77+
* `[resolvedAt, nextBoundary)` — the interval on which every `isGrantActive`
78+
* verdict the resolution made is unchanged.
79+
* - **Bypass is bypass.** A `bypassGrantsCache` caller reads nothing from the
80+
* memo and writes nothing into it, exactly as it treats the grants cache.
81+
* - **Failures are not remembered.** Only a resolution that completed is
82+
* stored; a read that threw (`AuthzStoreUnavailableError`) leaves the next
83+
* caller to issue its own reads, as before.
84+
*
85+
* Served values are clones — the hook puts its `positions` array into the
86+
* session payload and the resolver puts its own into the request context, and
87+
* downstream code may mutate either; the two must never alias.
88+
*
89+
* ⚠️ What the observer cannot see, stated exactly. The engine snapshots its
90+
* middleware list when a write starts, so a write that STARTED before the
91+
* observer was registered on that engine never passes it. The observer is
92+
* registered at the entry of the first `resolveAuthzContext` call that names
93+
* the engine (and on first sight of any other engine a resolution reads), and
94+
* the scope that registers it stores nothing for that engine — that request
95+
* reads twice. What stays open is narrower: a write that began before that
96+
* first call and is still in flight when a LATER request's resolution opens,
97+
* landing between that resolution and its step 2. It is invisible to the
98+
* epoch (it bumped before) and to the observer (it never enters it). Writes
99+
* from another process are invisible here too: their rows are read as of the
100+
* first resolution, a few milliseconds earlier than the second used to read
101+
* them — the same answer a write committing just after the second read always
102+
* got.
103+
*
104+
* The same holds for a write executed on an `engine.transaction()` whose
105+
* COMMIT lands between the first resolution and step 2. Its statements pass
106+
* the observer, which settles per statement, but the rows become visible only
107+
* at the driver commit, which runs outside every middleware chain. The window
108+
* is that one commit round trip after the transaction's last statement. It is
109+
* reachable on driver-sql deployments (SCIM through the better-auth adapter,
110+
* REST `/batch`), and not on the Turso remote face, which declares
111+
* `transactionsUnsupported` and so opens no transaction. The answer is the
112+
* out-of-process one: that request is authorised as of the first resolution,
113+
* and the next request reads fresh. Closing it engine-side (an objectql epoch
114+
* bump after an owned transaction's commit) is out of scope here.
115+
*
116+
* The cost: on an engine with a concurrent write, step 2 reads afresh. That
117+
* includes a session read that writes inside the request (the first request on
118+
* a fresh auth instance generates its signing key; `enforceSessionControls`
119+
* stamps `sys_session.last_activity_at` about once a minute per session when an
120+
* idle timeout is configured) — the safe direction, with no saving on that
121+
* request.
122+
*/
123+
124+
import { AsyncLocalStorage } from 'node:async_hooks';
125+
126+
import type { ResolveUserAuthzGrantsOptions, UserAuthzGrants } from './resolve-authz-context.js';
127+
128+
// ── The engine write observer ────────────────────────────────────────────────
129+
130+
/**
131+
* Per-engine write counters, advanced by {@link observeEngineWrites}'s
132+
* middleware. `started - completed` is the number of writes inside the
133+
* observer right now.
134+
*/
135+
interface EngineWriteObserver {
136+
started: number;
137+
completed: number;
138+
}
139+
140+
/**
141+
* Keyed on the engine instance: two engines in one process never share
142+
* counters, and a dropped engine takes its counters with it. `null` records an
143+
* engine whose middleware registration THREW — poisoned, never memoised
144+
* behind, as the grants cache poisons a half-wired seam.
145+
*/
146+
const engineWriteObservers = new WeakMap<object, EngineWriteObserver | null>();
147+
148+
/**
149+
* The engine operations that read. Everything else the chain runs is counted
150+
* as a write — including an operation this list has never heard of, so a new
151+
* write verb is observed by default (the safe direction: a new READ verb would
152+
* only cost a declined memo while one is in flight). The engine's own epoch
153+
* advances for `insert` / `update` / `delete`.
154+
*/
155+
const READ_OPERATIONS: ReadonlySet<string> = new Set(['find', 'findOne', 'count', 'aggregate']);
156+
157+
interface MiddlewareSeam {
158+
registerMiddleware?: unknown;
159+
}
160+
161+
/**
162+
* Fetch — and on first sight of an engine, register — the write observer.
163+
* Returns `{ observer, attachedNow }`, or `undefined` for an engine without
164+
* `registerMiddleware` and for one whose registration threw.
165+
*/
166+
function observeEngineWrites(ql: object): { observer: EngineWriteObserver; attachedNow: boolean } | undefined {
167+
const existing = engineWriteObservers.get(ql);
168+
if (existing !== undefined) return existing ? { observer: existing, attachedNow: false } : undefined;
169+
170+
const register = (ql as MiddlewareSeam).registerMiddleware;
171+
if (typeof register !== 'function') return undefined;
172+
173+
const observer: EngineWriteObserver = { started: 0, completed: 0 };
174+
try {
175+
(register as (
176+
fn: (ctx: { operation?: unknown }, next: () => Promise<void>) => Promise<void>,
177+
) => void).call(ql, async (ctx, next) => {
178+
const operation = ctx?.operation;
179+
if (typeof operation === 'string' && READ_OPERATIONS.has(operation)) return next();
180+
observer.started += 1;
181+
try {
182+
await next();
183+
} finally {
184+
observer.completed += 1;
185+
}
186+
});
187+
} catch {
188+
engineWriteObservers.set(ql, null);
189+
return undefined;
190+
}
191+
engineWriteObservers.set(ql, observer);
192+
return { observer, attachedNow: true };
193+
}
194+
195+
// ── The request scope ────────────────────────────────────────────────────────
196+
197+
interface RequestGrantsMemoEntry {
198+
/** A private clone of the resolved envelope. Never handed out directly. */
199+
value: UserAuthzGrants;
200+
/** The engine write epoch read when the resolution opened, before any read. */
201+
epochAtOpen: number;
202+
/** The observer's `started` when the resolution opened. */
203+
startedAtOpen: number;
204+
/** The observer's `completed` when the resolution opened. */
205+
completedAtOpen: number;
206+
/** The clock every `isGrantActive` verdict of the resolution was taken at. */
207+
resolvedAtMs: number;
208+
/** The earliest validity boundary after `resolvedAtMs`, if any row has one. */
209+
nextBoundaryMs: number | undefined;
210+
}
211+
212+
interface RequestGrantsMemoScope {
213+
/** False once the owning `resolveAuthzContext` call has settled. */
214+
open: boolean;
215+
/** Outer key: the engine. Inner key: {@link memoKey}. */
216+
entries: WeakMap<object, Map<string, RequestGrantsMemoEntry>>;
217+
/**
218+
* Engines whose observer THIS scope registered. A write already in flight at
219+
* registration never passes the observer, so this scope stores nothing for
220+
* them.
221+
*/
222+
attachedHere: WeakSet<object>;
223+
}
224+
225+
const scopeStorage = new AsyncLocalStorage<RequestGrantsMemoScope>();
226+
227+
/**
228+
* Run `fn` — one `resolveAuthzContext` body — inside a fresh memo scope, and
229+
* close the scope when it settles. Nested calls each get their own scope.
230+
* `ql` is the engine the body resolves against (`undefined` when it carries no
231+
* write epoch, so the memo can never serve there); its write observer is
232+
* registered here, before any read, when this is the first call to name it.
233+
*/
234+
export async function withRequestGrantsMemo<T>(ql: unknown, fn: () => Promise<T>): Promise<T> {
235+
const scope: RequestGrantsMemoScope = { open: true, entries: new WeakMap(), attachedHere: new WeakSet() };
236+
if (ql && typeof ql === 'object' && observeEngineWrites(ql)?.attachedNow) scope.attachedHere.add(ql);
237+
try {
238+
return await scopeStorage.run(scope, fn);
239+
} finally {
240+
scope.open = false;
241+
scope.entries = new WeakMap();
242+
}
243+
}
244+
245+
/**
246+
* JSON, not delimiters — a seed permission is caller-supplied text and must not
247+
* be able to alias another key by containing a separator. The spelling is the
248+
* grants cache's (`grantsCacheKey`): `null` and `undefined` collapse, which is
249+
* behaviour-preserving because the resolver only ever tests the tenant and the
250+
* seed email for truthiness, and an absent seed list resolves exactly as `[]`.
251+
*/
252+
function memoKey(userId: string, opts: ResolveUserAuthzGrantsOptions): string {
253+
return JSON.stringify([
254+
userId,
255+
opts.tenantId ?? null,
256+
opts.seedEmail ?? null,
257+
Array.isArray(opts.seedPermissions) ? opts.seedPermissions : [],
258+
]);
259+
}
260+
261+
/** One resolution's interaction with the memo, opened at the top of `resolveUserAuthzGrants`. */
262+
export interface RequestGrantsMemoAttempt {
263+
/** The envelope an earlier resolution in this request produced, cloned for the caller. */
264+
hit?: UserAuthzGrants;
265+
/**
266+
* Store a freshly resolved envelope. `resolvedAtMs` is the clock the
267+
* resolution's validity verdicts were taken at; `nextBoundaryMs` is
268+
* `nextGrantValidityBoundary` over the rows those verdicts were taken on.
269+
*/
270+
commit(grants: UserAuthzGrants, resolvedAtMs: number, nextBoundaryMs: number | undefined): void;
271+
}
272+
273+
/**
274+
* Open the memo for one resolution. Returns `undefined` — the plain fresh
275+
* path, no side effects beyond registering the engine's write observer — outside
276+
* a `resolveAuthzContext` scope or after it closed, for a `bypassGrantsCache`
277+
* caller, and for a `ql` that is not an object, carries no write epoch
278+
* (`epochNow` undefined) or cannot register a middleware.
279+
*
280+
* `epochNow` is the engine's write epoch as `resolveUserAuthzGrants` reads it
281+
* (`readWriteEpoch`), passed in rather than re-derived so that the one
282+
* structural check of that seam stays where it is.
283+
*/
284+
export function openRequestGrantsMemo(
285+
ql: unknown,
286+
userId: string,
287+
opts: ResolveUserAuthzGrantsOptions,
288+
epochNow: number | undefined,
289+
): RequestGrantsMemoAttempt | undefined {
290+
if (opts.bypassGrantsCache) return undefined;
291+
const scope = scopeStorage.getStore();
292+
if (!scope || !scope.open) return undefined;
293+
if (!ql || typeof ql !== 'object' || epochNow === undefined) return undefined;
294+
const observed = observeEngineWrites(ql);
295+
if (!observed) return undefined;
296+
if (observed.attachedNow) scope.attachedHere.add(ql);
297+
const { observer } = observed;
298+
299+
const key = memoKey(userId, opts);
300+
const now = opts.nowMs ?? Date.now();
301+
const existing = scope.entries.get(ql)?.get(key);
302+
if (
303+
existing
304+
&& existing.epochAtOpen === epochNow
305+
&& existing.startedAtOpen === observer.started
306+
&& existing.completedAtOpen === observer.completed
307+
&& observer.started === observer.completed
308+
&& existing.resolvedAtMs <= now
309+
&& (existing.nextBoundaryMs === undefined || now < existing.nextBoundaryMs)
310+
) {
311+
return { hit: structuredClone(existing.value), commit: () => {} };
312+
}
313+
314+
const startedAtOpen = observer.started;
315+
const completedAtOpen = observer.completed;
316+
return {
317+
commit(grants, resolvedAtMs, nextBoundaryMs) {
318+
if (!scope.open || scope.attachedHere.has(ql)) return;
319+
let perEngine = scope.entries.get(ql);
320+
if (!perEngine) {
321+
perEngine = new Map();
322+
scope.entries.set(ql, perEngine);
323+
}
324+
perEngine.set(key, {
325+
value: structuredClone(grants),
326+
epochAtOpen: epochNow,
327+
startedAtOpen,
328+
completedAtOpen,
329+
resolvedAtMs,
330+
nextBoundaryMs,
331+
});
332+
},
333+
};
334+
}

0 commit comments

Comments
 (0)