Skip to content
Merged
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
59 changes: 59 additions & 0 deletions .changeset/authz-cache-invalidation-substrate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
"@objectstack/objectql": minor
"@objectstack/core": minor
"@objectstack/service-cluster": minor
"@objectstack/runtime": minor
"@objectstack/plugin-security": patch
---

feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968)

The substrate step (§10.3) of the accepted #11633 cross-request caching design
(maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation
machinery once, before the grants cache (#11967) that will consume it, so that
leg does not carry it. **Nothing here caches anything.**

- **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine
middleware seam on every `insert` / `update` / `delete`, ahead of the whole
chain (and so ahead of any `isSystem` bypass a middleware applies). It
generalises the private counter `@objectstack/plugin-security` has carried
since #10757: the mechanism was always the engine's, and hoisting it lets a
second consumer share **one** signal instead of minting a parallel one that
watches a different set of writes. A seam rather than a list of call sites,
because a forgotten call site fails as silent over-permission and writing
through the engine is the only way to write at all — including better-auth's
own adapter.
- **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged
in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a
consuming cache carries is the correctness contract; this channel is not.** No
shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a
missed message is *expected*, the bridge stays out of the write path (a
publish failure is logged and swallowed, never awaited by the writer), and the
channel only moves the *typical* convergence from one TTL to one network hop.
That statement lives in the code at the channel, where a consumer reads it.
- **The boot-time posture statement** — non-optional by the ruling. Whenever a
grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no
cross-node invalidation bus, the deployment is told so at `warn`, every boot,
naming the window it accepted and the remedy. It is a statement, not a
refusal: a TTL-bounded per-process cache is a legitimate configuration. It is
said out loud because a silently-absent invalidation bridge is how a security
control gets disabled with nobody noticing (#4785). The in-process `memory`
driver counts as **no** bus — a cluster service exists on the shipped default
while fanning out to nobody, which is the case a "is a cluster service
registered?" check answers `yes` to and is wrong about.

**Runtime behaviour is unchanged.** With no cache consumer the epoch has zero
subscribers, so nothing is published and nothing is invalidated; with the
shipped default TTL of `0` the bridge attaches nothing and logs nothing above
`debug`. The one composition change worth naming: `Runtime` now registers
`AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false`
— that is not an oversight, it is the loudest case the posture check has, and
skipping it there would put the statement's absence exactly where the missing
bus is.

`@objectstack/plugin-security` is a `patch`: its permission-set memo now reads
the engine's epoch when the wired engine exposes one and keeps its private
counter otherwise (test doubles, embeddings). The covered set of writes is
identical — the plugin's own middleware was already global — and it is now
identical *by construction* rather than by two files agreeing on which
operations count.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +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. |

---

Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/security/authz-cache-posture.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import {
AUTHZ_GRANTS_CACHE_TTL_ENV,
readAuthzGrantsCacheTtlMs,
reportAuthzCachePosture,
resolveAuthzCachePosture,
type AuthzInvalidationBusState,
} from './authz-cache-posture.js';

/**
* #11968 — the boot-time posture statement, pinned on BOTH arms.
*
* The acceptance criterion of the substrate card is a biconditional: the line
* appears **exactly when** a cache flag is on without a bus, **and not
* otherwise**. Each arm alone is passed by a broken implementation — "appears"
* alone is satisfied by one that prints always, "silent" alone by one that
* never prints — so both are asserted here, and the exhaustive matrix below is
* what makes "exactly when" a measured claim rather than a described one.
*/

const BUS_STATES: AuthzInvalidationBusState[] = ['bridged', 'in-process', 'absent'];

function makeSink() {
return {
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
}

describe('#11968 authz cache posture — the loud arm', () => {
it('warns when a cache is enabled and NO cluster service is registered', () => {
const sink = makeSink();
const statement = reportAuthzCachePosture({ ttlMs: 5000, bus: 'absent' }, sink);

expect(statement.posture).toBe('ttl-only');
expect(statement.loud).toBe(true);
expect(sink.warn).toHaveBeenCalledTimes(1);
expect(sink.info).not.toHaveBeenCalled();
});

it('warns when a cluster service exists but its driver is in-process', () => {
// The case that would otherwise slip through: `Runtime` registers the
// memory driver by DEFAULT, so "is a cluster service registered?" answers
// yes while the bus fans out to nobody.
const sink = makeSink();
const statement = reportAuthzCachePosture(
{ ttlMs: 5000, bus: 'in-process', driver: 'memory' },
sink,
);

expect(statement.posture).toBe('ttl-only');
expect(sink.warn).toHaveBeenCalledTimes(1);
expect(sink.warn.mock.calls[0][0]).toContain('memory');
});

it('the loud line names the window, the remedy and that it is not an error', () => {
// A warning nobody can act on gets muted, and a warning that reads as a
// failure gets "fixed" by turning the cache off. Both halves are content.
const { message } = resolveAuthzCachePosture({ ttlMs: 7500, bus: 'absent' });

expect(message).toContain('7500ms');
expect(message).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV);
expect(message).toMatch(/not an error/i);
expect(message).toContain('#4785');
});
});

describe('#11968 authz cache posture — the silent arm', () => {
it.each(BUS_STATES)(
'says NOTHING when the cache is disabled (ttl=0, bus=%s)',
(bus) => {
const sink = makeSink();
const statement = reportAuthzCachePosture({ ttlMs: 0, bus }, sink);

expect(statement.posture).toBe('disabled');
expect(statement.message).toBe('');
expect(sink.warn).not.toHaveBeenCalled();
expect(sink.info).not.toHaveBeenCalled();
},
);

it('does not warn when the cache is enabled AND the bus is bridged', () => {
const sink = makeSink();
const statement = reportAuthzCachePosture(
{ ttlMs: 5000, bus: 'bridged', driver: 'redis' },
sink,
);

expect(statement.posture).toBe('bus-narrowed');
expect(statement.loud).toBe(false);
expect(sink.warn).not.toHaveBeenCalled();
expect(sink.info).toHaveBeenCalledTimes(1);
});

it('a negative TTL is off, not a degenerate enabled cache', () => {
const sink = makeSink();
expect(reportAuthzCachePosture({ ttlMs: -1, bus: 'absent' }, sink).posture).toBe(
'disabled',
);
expect(sink.warn).not.toHaveBeenCalled();
});
});

describe('#11968 authz cache posture — "exactly when", as a matrix', () => {
// The biconditional itself. Enumerated rather than described, so an
// implementation that prints always or never fails here and not only in prose.
const ttls = [0, 1, 5000];
const expectedLoud = new Set(['1|in-process', '1|absent', '5000|in-process', '5000|absent']);

for (const ttlMs of ttls) {
for (const bus of BUS_STATES) {
const key = `${ttlMs}|${bus}`;
const shouldBeLoud = expectedLoud.has(key);
it(`ttl=${ttlMs} bus=${bus} -> ${shouldBeLoud ? 'LOUD' : 'quiet'}`, () => {
const sink = makeSink();
reportAuthzCachePosture({ ttlMs, bus, driver: 'memory' }, sink);
expect(sink.warn.mock.calls.length > 0).toBe(shouldBeLoud);
});
}
}
});

describe('#11968 grants-cache TTL reading', () => {
it('defaults to 0 — the cache is off unless a deployment turns it on', () => {
expect(readAuthzGrantsCacheTtlMs({})).toEqual({ ttlMs: 0, malformed: false });
});

it('an explicit 0 is a real path, not a degenerate TTL', () => {
expect(readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '0' })).toEqual({
ttlMs: 0,
raw: '0',
malformed: false,
});
});

it('reads a millisecond count', () => {
expect(
readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: ' 5000 ' }).ttlMs,
).toBe(5000);
});

it('a malformed value is reported as malformed, never folded into "off"', () => {
// `5OOO` with letter O resolving silently to "disabled" is the same
// silent-disable class the posture statement exists to prevent.
const reading = readAuthzGrantsCacheTtlMs({
[AUTHZ_GRANTS_CACHE_TTL_ENV]: '5OOO',
});
expect(reading).toEqual({ ttlMs: 0, raw: '5OOO', malformed: true });

const sink = makeSink();
reportAuthzCachePosture(
{ ttlMs: reading.ttlMs, bus: 'absent', malformedTtl: { raw: reading.raw } },
sink,
);
expect(sink.warn).toHaveBeenCalledTimes(1);
expect(sink.warn.mock.calls[0][0]).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV);
});

it('a negative value is malformed, not a clamp', () => {
expect(
readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '-5' }).malformed,
).toBe(true);
});
});
Loading
Loading