You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* fix(security): pre-wiring identity admission for GraphQL + realtime surfaces (#2992, ADR-0096 D4)
Two latent execution surfaces dropped/lacked the caller identity and would
have fallen open the instant a real client transport was wired. Fix the
identity story now and pin it in CI, per ADR-0096:
GraphQL (surface 1 — context-drop, now threaded):
- handleGraphQL resolved identity only under requireAuth and passed only
{ request } to kernel.graphql, dropping the ExecutionContext. It now
resolves the caller identity even on the direct dispatcher-plugin route
and even when requireAuth is off, and threads it as options.context —
so the first real engine runs caller-scoped, never context-less
(the security middleware falls OPEN on a missing principal).
- IGraphQLService.execute documents the admission requirement: forward
the context to every data-engine call as options.context.
- Matrix row graphql-identity-thread + a source probe pin the threading:
removing `context:` from the kernel.graphql call goes STALE → red CI.
- Unit tests cover user/system/guest threading postures.
realtime (surface 2 — no per-recipient authz seam, posture registered):
- Matrix row realtime-delivery-authz registers the honest posture: pure
fan-out, subscriptions carry no principal, full after-row payload —
trusted server-internal subscribers only, with the admission
requirement (per-recipient RLS/FLS/tenant re-check on delivery, or
id-only payload + client re-fetch) stated on the row.
- Transport TRIPWIRE probes (adapter handleUpgrade, plugin transport,
dispatcher handleRealtime/Upgrade/Subscribe, client WebSocket/
EventSource, rest /realtime route) discover keys covered by NO row —
wiring a transport fails CI as UNCLASSIFIED until the identity story
ships with it. Ratchet-bites tests prove both new pins fire.
- service-realtime README rewritten: it advertised authorizeChannel /
broadcastToUser / presence auth / rooms that do not exist. It now
documents the real surface and the security posture; the contract and
the publish fan-out carry the same admission note at the seam.
Closes#2992.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvQFjTKVdP5HUPxzcBvpCF
* docs(realtime): add the #2992 identity-admission requirement to the realtime protocol status banner
The page's planned wire protocol shows only a subscribe-time permission
error; the admission requirement is per-delivery re-authorization (or
id-only payloads). State it where a transport implementer will read first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvQFjTKVdP5HUPxzcBvpCF
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: content/docs/protocol/kernel/realtime-protocol.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -11,6 +11,8 @@ The **Real-Time Protocol** describes how live data synchronization is intended t
11
11
12
12
<Callouttype="warn">
13
13
**Implementation status (v1):** The shipping realtime service is an **in-memory pub/sub adapter** (`@objectstack/service-realtime`, `InMemoryRealtimeAdapter`) plus a **long-polling** client (`RealtimeAPI` in `@objectstack/client`). The `IRealtimeService` contract reserves an optional `handleUpgrade()` for a WebSocket handshake, but **no WebSocket (`/ws`) or SSE (`/api/v1/stream`) transport is wired up yet** — those sections below document the planned wire protocol, not a deployed endpoint. The in-memory adapter is **single-instance only** (v1 deployment contract); a Redis-backed adapter for multi-node HA is a post-GA fast-follow. Treat the WebSocket/SSE message formats, connection limits, and debug endpoints in this page as a forward-looking design spec until that transport lands.
14
+
15
+
**Identity admission (framework#2992, ADR-0096 D4):** today's delivery path is a trusted server-internal fan-out with **no per-recipient authorization** — subscriptions carry no principal and events carry the full record body. Before any client transport ships, delivery must re-check each subscriber's authority (RLS/FLS/tenant) per event — the subscribe-time permission check shown below is *not* sufficient — or switch to id-only payloads with client re-fetch. The authz conformance matrix (`realtime-delivery-authz` row + transport tripwires) enforces this in CI.
note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.'},
// admission). Neither surface is reachable by a client today; these rows
81
+
// register their identity posture NOW so the ratchet (see the probes +
82
+
// transport tripwires in authz-conformance.test.ts) blocks wiring a client
83
+
// transport without the identity story — in CI, not in an adversarial
84
+
// review after the fact.
85
+
{id: 'graphql-identity-thread',summary: 'GraphQL entry point threads the caller identity to the engine (#2992 surface 1, ADR-0096 D1)',state: 'enforced',
86
+
enforcement: 'runtime/http-dispatcher.ts handleGraphQL — resolves the caller ExecutionContext (also on the direct dispatcher-plugin route, requireAuth on or off) and threads it as options.context on every kernel.graphql call; spec IGraphQLService.execute documents that implementations MUST forward it to ObjectQL as options.context',
note: 'Surface posture: user (caller identity), latent — kernel.graphql is never assigned in the monorepo, so every POST /graphql 501s before an engine call; the only IGraphQLService is the plugin-dev stub. The threading exists so the FIRST real engine runs caller-scoped instead of context-less (the security middleware falls OPEN on a missing principal = full authority). Threading unit-proven in runtime/http-dispatcher.requireauth.test.ts (identity threading block); removing it goes STALE here and fails CI.'},
89
+
{id: 'realtime-delivery-authz',summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)',state: 'experimental',
note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.'},
it('threads the caller identity even when requireAuth is OFF (an authenticated caller on an open deployment still runs under their own authority)',async()=>{
it('an anonymous caller on an open deployment carries NO authority (explicit guest principal or nothing — never a forged user/system identity)',async()=>{
0 commit comments