Repository navigation
Commit c7a3234
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
- .changeset
- packages
- core/src/security
- plugins/plugin-auth/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
0 commit comments