Skip to content

Commit 86cbe37

Browse files
Elon Muskclaude
andauthored
feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list (#13415)
* feat(core): cross-request grants cache — #11633 leg B, coarse invalidation, TTL=0 default, expiry-boundary rule, ruled bypass list The UserAuthzGrants envelope resolveUserAuthzGrants produces can now be cached across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS (default 0 = off, a real path with zero engine footprint). When enabled: coarse invalidation on any engine write to a watched authorization object (sys_session deliberately excluded — the measured keying trap), wholesale retirement on non-write epoch reasons (metadata/remote/manual), entry expiry at min(ttl, nextValidityBoundary) because ADR-0091 windows flip with no write anywhere, seeds in the cache key (measured: seeding is NOT a pure prepend — posture and the suppressed sys_user read both depend on seeds), and clones served so callers cannot poison entries. The explain engine and runAs:'user' automation runs take the ruled force-fresh path. Pins: identity over the 11-fixture matrix (zero reads on a hit), read-after-write revocation/grant with no clock advance, the sys_session trap, validity boundaries in both directions, peer-membership coarse retirement, seed isolation, TTL=0 bit-identity, and the two-node bus/TTL convergence pair. The batch-equivalence harness moved to a .testkit.ts so the identity pins reuse the fixtures without re-registering that suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi * test(core): pin the seam double's write verbs to the engine dispatch contract check:engine-double-contract flagged makeSeamQl — its update()/delete() accepted calls the real ObjectQL refuses. Both verbs now open with the producer's own predicates (assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core — the non-cycle edge for a package objectql depends on), the pins spell their writes legally (multi delete carries multi:true, the session-activity update is by-id), and the RETAINED ledger records the new pinned double. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi * fix(core): classify the batch-equivalence testkit as test code by path — census population restored to baseline CI's check:system-context-census (landed on main after this branch was cut, outside the path-derived gate set) went red on the harness extraction: the recording double's isSystem-recording line, excluded from the census for as long as it lived in a .test.ts file, entered the population when it moved to a sibling .testkit.ts — the census classifies test code BY PATH (.test. / .spec. / tests/ / __tests__/ / qa/) and the .testkit.ts suffix is not in that rule. The file is test scaffolding (consumed only by suites, extracted from one), so the honest repair is classification, not a hand-written page row: it now lives under security/__tests__/, inside the census's own published exclusion, and the population returns to its 109-site / 45-file baseline — page, counts, and the #4707 ruling's quoted premise all untouched. Whether .testkit.ts should join the census's path rule generally is the gate owner's call and is reported on the card, not taken here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89448a5 commit 86cbe37

12 files changed

Lines changed: 1455 additions & 321 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-security": patch
4+
"@objectstack/service-automation": patch
5+
---
6+
7+
feat(core): cross-request authorization grants cache — leg B of #11633 (#11971)
8+
9+
`resolveUserAuthzGrants` can now cache its resolved envelope across requests,
10+
governed by `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. **The default is `0` — the cache is
11+
OFF and the shipped behaviour is unchanged** (Fork 4 of the accepted #11633
12+
design): a deployment that enables it accepts the configured staleness window
13+
explicitly, and the boot-time posture statement says so out loud when no
14+
cross-node invalidation bus is attached.
15+
16+
With the cache on:
17+
18+
- **Coarse write-invalidation (Fork 1A).** Any engine write to a watched
19+
authorization object (`sys_member`, `sys_user_position`,
20+
`sys_user_permission_set`, `sys_position`, `sys_position_permission_set`,
21+
`sys_permission_set`, `sys_user`) retires every entry on the writing node —
22+
a grant/revoke/role change is observed by the very next request there, by
23+
invalidation and not by TTL. `metadata.changed` and peer-node
24+
`authz.invalidated` hints retire wholesale via the engine write epoch.
25+
`sys_session` is deliberately not watched (its once-a-minute
26+
`last_activity_at` cadence would turn the cache into a non-cache).
27+
- **Expiry-boundary rule.** Entries expire at `min(ttl, nextBoundary)`, where
28+
`nextBoundary` is the earliest upcoming ADR-0091 `valid_from`/`valid_until`
29+
among the rows consulted — a validity window flipping is a permission change
30+
with no write anywhere, so the timer is the only mechanism for that class.
31+
- **Ruled bypass list.** The permission explainer
32+
(`plugin-security` `buildContextForUser`) and `runAs:'user'` automation runs
33+
(`service-automation`) always resolve fresh, and never populate the cache.
34+
- The TTL remains the correctness contract; the `authz.invalidated` bus only
35+
narrows the typical cross-node window (no shipped driver exceeds
36+
at-most-once delivery).

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
6565
| `OS_DEV_CRYPTO_KEY` | string || Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. |
6666
| `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). |
6767
| `OS_REDIS_URL` | url || Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). |
68-
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
68+
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
6969

7070
---
7171

0 commit comments

Comments
 (0)