Skip to content

Commit 59cd765

Browse files
os-zhuangclaude
andauthored
fix(security): pre-wiring identity admission for GraphQL + realtime surfaces (#2992, ADR-0096 D4) (#3013)
* 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>
1 parent fad8e49 commit 59cd765

10 files changed

Lines changed: 333 additions & 404 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/service-realtime': patch
5+
---
6+
7+
fix(security): pre-wiring identity admission for the GraphQL and realtime surfaces (#2992, ADR-0096 D4)
8+
9+
Two latent execution surfaces — neither reachable by a client today — would
10+
have fallen open the instant a real transport was wired, because both drop or
11+
lack the caller's identity. Per ADR-0096, the identity story is fixed and
12+
pinned in CI *before* wiring, not after an adversarial review:
13+
14+
- **GraphQL (surface 1 — latent context-drop, now threaded).**
15+
`handleGraphQL` passed only `{ request }` to `kernel.graphql`, dropping the
16+
resolved `ExecutionContext` — the moment a real engine resolved objects
17+
through ObjectQL it would have run context-less (security middleware falls
18+
OPEN on a missing principal = full authority). The entry point now resolves
19+
the caller identity even on the direct dispatcher-plugin route and even when
20+
`requireAuth` is off, and threads it as `options.context`;
21+
`IGraphQLService.execute` documents that implementations MUST forward it to
22+
every data-engine call. Unit-proven; the authz conformance matrix pins the
23+
threading (`graphql-identity-thread` row) so removing it goes STALE and
24+
fails CI.
25+
26+
- **realtime (surface 2 — no per-recipient authz seam, posture registered).**
27+
Delivery is a pure fan-out (subscriptions carry no principal,
28+
`matchesSubscription` filters only by object+eventTypes, the engine
29+
publishes the full `after` row), safe only while every subscriber is
30+
server-internal. The posture is now registered as an `experimental` matrix
31+
row (`realtime-delivery-authz`) stating the admission requirement
32+
(per-recipient RLS/FLS/tenant re-check on delivery, or id-only payload +
33+
client re-fetch), and transport TRIPWIRE probes turn any newly wired
34+
WebSocket/SSE/subscribe/client transport into an UNCLASSIFIED surface → red
35+
CI until the identity story ships with it. The `service-realtime` README —
36+
which advertised `authorizeChannel`/`broadcastToUser`/presence auth that do
37+
not exist — is rewritten to describe the real, trusted-internal-only
38+
surface, and the contract docs carry the admission requirement at the seam.

content/docs/protocol/kernel/realtime-protocol.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The **Real-Time Protocol** describes how live data synchronization is intended t
1111

1212
<Callout type="warn">
1313
**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.
1416
</Callout>
1517

1618
## Why Real-Time Matters

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
7575
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
7676
covers: ['data:hono-plugin.ts:POST /data/:object', 'data:hono-plugin.ts:GET /data/:object/:id', 'data:hono-plugin.ts:GET /data/:object'],
7777
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.' },
78+
79+
// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
80+
// 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',
87+
covers: ['graphql:http-dispatcher.ts:kernel.graphql(context-threaded)'],
88+
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',
90+
covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'],
91+
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.' },
7892
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
7993
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
8094

packages/dogfood/test/authz-conformance.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,60 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
6060
re: /rawApp\.(get|post|put|patch|delete)\(\s*`\$\{prefix\}(\/data[^`]*)`/g,
6161
key: (m) => `data:hono-plugin.ts:${m[1].toUpperCase()} ${m[2]}`,
6262
},
63+
64+
// ── #2992 / ADR-0096 D4 — latent-surface identity pins ─────────────────
65+
// GraphQL identity threading: the ONLY kernel.graphql(...) call site must
66+
// carry `context:` in its options. If a refactor drops the threading the key
67+
// vanishes → the `graphql-identity-thread` row's covers goes STALE → red CI.
68+
{
69+
file: 'packages/runtime/src/http-dispatcher.ts',
70+
re: /kernel\.graphql\([^)]*\bcontext:/g,
71+
key: () => 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)',
72+
},
73+
// Realtime delivery fan-out: pins the trusted-internal-only posture of the
74+
// in-memory adapter's publish loop (`realtime-delivery-authz` row).
75+
{
76+
file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts',
77+
re: /async\s+publish\s*\(/g,
78+
key: () => 'realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)',
79+
},
80+
81+
// ── #2992 transport TRIPWIRES — deliberately covered by NO row ──────────
82+
// Delivery today is a pure fan-out with no per-recipient authorization
83+
// (subscriptions carry no principal, payload is the full record), which is
84+
// safe ONLY while every subscriber is server-internal. These patterns match
85+
// nothing today; the moment someone wires an end-user realtime transport
86+
// (WebSocket handshake, SSE, a client transport) a NEW key appears →
87+
// UNCLASSIFIED surface → red CI with this checklist: add per-recipient
88+
// RLS/FLS/tenant re-check on delivery (or switch to id-only payloads),
89+
// THEN register the enforcement site in a matrix row covering the new key.
90+
{
91+
file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts',
92+
re: /handleUpgrade\s*\(/g,
93+
key: () => 'realtime:in-memory-realtime-adapter.ts:handleUpgrade(TRANSPORT-WIRED)',
94+
},
95+
{
96+
file: 'packages/services/service-realtime/src/realtime-service-plugin.ts',
97+
re: /handleUpgrade\s*\(|new\s+WebSocketServer|text\/event-stream/g,
98+
key: () => 'realtime:realtime-service-plugin.ts:transport(TRANSPORT-WIRED)',
99+
},
100+
{
101+
file: 'packages/runtime/src/http-dispatcher.ts',
102+
re: /async\s+handle(Realtime|Upgrade|Subscribe)\w*\s*\(/g,
103+
key: (m) => `realtime:http-dispatcher.ts:handle${m[1]}(TRANSPORT-WIRED)`,
104+
},
105+
{
106+
file: 'packages/client/src/realtime-api.ts',
107+
re: /new\s+WebSocket\b|new\s+EventSource\b/g,
108+
key: () => 'realtime:client/realtime-api.ts:transport(TRANSPORT-WIRED)',
109+
},
110+
// packages/rest/src has ZERO realtime refs today (#2992) — a `/realtime`
111+
// route literal appearing there is a subscribe endpoint. Same tripwire.
112+
{
113+
file: 'packages/rest/src/rest-server.ts',
114+
re: /['"`][^'"`]*\/realtime[^'"`]*['"`]/g,
115+
key: () => 'realtime:rest-server.ts:route(TRANSPORT-WIRED)',
116+
},
63117
];
64118

65119
/** Statically enumerate the anonymous-deny HTTP entry points from source. */
@@ -132,4 +186,25 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
132186
const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces()));
133187
expect(problems.some((p) => /STALE covers/.test(p) && /handleRemovedThing/.test(p))).toBe(true);
134188
});
189+
190+
// ── #2992 — the latent-surface pins bite too ──────────────────────────
191+
it('(d) wiring a realtime transport (tripwire key appears) → UNCLASSIFIED surface failure (#2992)', () => {
192+
const fake = 'realtime:in-memory-realtime-adapter.ts:handleUpgrade(TRANSPORT-WIRED)';
193+
const problems = checkLedger(
194+
AUTHZ_CONFORMANCE,
195+
opts(() => new Set([...discoverAnonymousDenySurfaces(), fake])),
196+
);
197+
expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fake))).toBe(true);
198+
});
199+
200+
it('(e) dropping the GraphQL context-thread → STALE covers failure (#2992)', () => {
201+
const threaded = 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)';
202+
// Baseline sanity: the threading is discovered from source today.
203+
expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true);
204+
const problems = checkLedger(
205+
AUTHZ_CONFORMANCE,
206+
opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== threaded))),
207+
);
208+
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true);
209+
});
135210
});

packages/runtime/src/http-dispatcher.requireauth.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,62 @@ describe('HttpDispatcher requireAuth gate — GraphQL (handleGraphQL)', () => {
100100
});
101101
});
102102

103+
// #2992 / ADR-0096 D1 — the GraphQL entry point must THREAD the caller's
104+
// identity to the engine, not just gate anonymity. kernel.graphql is a stub
105+
// surface today (never assigned in the monorepo), but the moment a real
106+
// engine lands it resolves objects through ObjectQL, whose security
107+
// middleware falls OPEN on a missing principal — so the entry point passes
108+
// the resolved ExecutionContext as `options.context` (the same key the REST
109+
// callData path threads). Dropping it also goes STALE in the authz
110+
// conformance matrix (dogfood/test/authz-conformance.test.ts).
111+
describe('HttpDispatcher identity threading — GraphQL (handleGraphQL, #2992)', () => {
112+
const gqlBody = { query: '{ __typename }', variables: { a: 1 } };
113+
114+
const makeGraphQLKernel = (calls: any[]) =>
115+
makeKernel({
116+
graphql: (query: string, variables: any, options: any) => {
117+
calls.push({ query, variables, options });
118+
return { data: {} };
119+
},
120+
});
121+
122+
it('threads the resolved ExecutionContext as options.context', async () => {
123+
const calls: any[] = [];
124+
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true });
125+
await d.handleGraphQL(gqlBody, authed);
126+
expect(calls).toHaveLength(1);
127+
expect(calls[0].query).toBe(gqlBody.query);
128+
expect(calls[0].variables).toEqual(gqlBody.variables);
129+
expect(calls[0].options.context).toBe(authed.executionContext);
130+
});
131+
132+
it('threads a system context unchanged', async () => {
133+
const calls: any[] = [];
134+
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true });
135+
await d.handleGraphQL(gqlBody, system);
136+
expect(calls[0].options.context).toBe(system.executionContext);
137+
});
138+
139+
it('threads the caller identity even when requireAuth is OFF (an authenticated caller on an open deployment still runs under their own authority)', async () => {
140+
const calls: any[] = [];
141+
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false });
142+
await d.handleGraphQL(gqlBody, authed);
143+
expect(calls[0].options.context).toBe(authed.executionContext);
144+
});
145+
146+
it('an anonymous caller on an open deployment carries NO authority (explicit guest principal or nothing — never a forged user/system identity)', async () => {
147+
const calls: any[] = [];
148+
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false });
149+
// Fresh context object: handleGraphQL caches the resolved identity on it.
150+
await d.handleGraphQL(gqlBody, { request: {}, executionContext: undefined } as any);
151+
const threaded = calls[0].options.context;
152+
// The resolver yields an explicit guest principal (mirroring dispatch());
153+
// whatever is threaded must carry no user and no system authority.
154+
expect(threaded?.userId).toBeUndefined();
155+
expect(threaded?.isSystem ?? false).toBe(false);
156+
});
157+
});
158+
103159
describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => {
104160
it('401s an anonymous caller when requireAuth is on', async () => {
105161
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });

packages/runtime/src/http-dispatcher.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,9 +1631,13 @@ export class HttpDispatcher {
16311631
//
16321632
// The dispatcher-plugin's direct `/graphql` route calls us WITHOUT
16331633
// resolving identity first (unlike `dispatch()`, which populates
1634-
// `context.executionContext`), so resolve it here when absent.
1634+
// `context.executionContext`), so resolve it here when absent —
1635+
// REGARDLESS of `requireAuth`: the resolved identity is also what we
1636+
// thread to the engine below (#2992), and an authenticated caller on a
1637+
// `requireAuth: false` deployment must still run under their own
1638+
// authority, not context-less.
16351639
let ec: any = context.executionContext;
1636-
if (this.requireAuth && !ec) {
1640+
if (!ec) {
16371641
ec = await this.resolveRequestExecutionContext(context);
16381642
if (ec) context.executionContext = ec;
16391643
}
@@ -1652,8 +1656,16 @@ export class HttpDispatcher {
16521656
throw { statusCode: 501, message: 'GraphQL service not available' };
16531657
}
16541658

1659+
// ADR-0096 D1 / #2992 — thread the caller's identity to the engine.
1660+
// `kernel.graphql` is still unassigned everywhere (this call 501s
1661+
// above), but the moment a real engine lands it resolves objects
1662+
// through ObjectQL, whose security middleware falls OPEN on a missing
1663+
// principal — so the entry point must already carry the caller as
1664+
// `options.context` (the same key the REST `callData` path threads).
1665+
// An implementation MUST forward it to every data-engine call.
16551666
return this.kernel.graphql(body.query, body.variables, {
1656-
request: context.request
1667+
request: context.request,
1668+
context: ec,
16571669
});
16581670
}
16591671

@@ -1663,8 +1675,10 @@ export class HttpDispatcher {
16631675
* `context.executionContext`). The dispatcher-plugin's direct `/graphql`
16641676
* route is the current caller. Mirrors the identity resolution `dispatch()`
16651677
* performs so an anonymous-deny gate can tell an authenticated caller from
1666-
* an anonymous one. Best-effort: returns `undefined` on failure (treated as
1667-
* anonymous, i.e. denied under `requireAuth`).
1678+
* an anonymous one, and so the caller's identity can be THREADED to the
1679+
* engine (#2992 / ADR-0096 D1) instead of dropped. Best-effort: returns
1680+
* `undefined` on failure (treated as anonymous, i.e. denied under
1681+
* `requireAuth`).
16681682
*/
16691683
private async resolveRequestExecutionContext(
16701684
context: HttpProtocolContext,

0 commit comments

Comments
 (0)