Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/authz-grants-cache-leg-b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/core": minor
"@objectstack/plugin-security": patch
"@objectstack/service-automation": patch
---

feat(core): cross-request authorization grants cache — leg B of #11633 (#11971)

`resolveUserAuthzGrants` can now cache its resolved envelope across requests,
governed by `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. **The default is `0` — the cache is
OFF and the shipped behaviour is unchanged** (Fork 4 of the accepted #11633
design): a deployment that enables it accepts the configured staleness window
explicitly, and the boot-time posture statement says so out loud when no
cross-node invalidation bus is attached.

With the cache on:

- **Coarse write-invalidation (Fork 1A).** Any engine 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`) retires every entry on the writing node —
a grant/revoke/role change is observed by the very next request there, by
invalidation and not by TTL. `metadata.changed` and peer-node
`authz.invalidated` hints retire wholesale via the engine write epoch.
`sys_session` is deliberately not watched (its once-a-minute
`last_activity_at` cadence would turn the cache into a non-cache).
- **Expiry-boundary rule.** Entries expire at `min(ttl, nextBoundary)`, where
`nextBoundary` is the earliest upcoming ADR-0091 `valid_from`/`valid_until`
among the rows consulted — a validity window flipping is a permission change
with no write anywhere, so the timer is the only mechanism for that class.
- **Ruled bypass list.** The permission explainer
(`plugin-security` `buildContextForUser`) and `runAs:'user'` automation runs
(`service-automation`) always resolve fresh, and never populate the cache.
- The TTL remains the correctness contract; the `authz.invalidated` bus only
narrows the typical cross-node window (no shipped driver exceeds
at-most-once delivery).
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. |
| `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). |
| `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). |
| `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. |
| `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. |

---

Expand Down
Loading
Loading