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
38 changes: 38 additions & 0 deletions .changeset/graphql-realtime-identity-2992.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/runtime': patch
'@objectstack/spec': patch
'@objectstack/service-realtime': patch
---

fix(security): pre-wiring identity admission for the GraphQL and realtime surfaces (#2992, ADR-0096 D4)

Two latent execution surfaces — neither reachable by a client today — would
have fallen open the instant a real transport was wired, because both drop or
lack the caller's identity. Per ADR-0096, the identity story is fixed and
pinned in CI *before* wiring, not after an adversarial review:

- **GraphQL (surface 1 — latent context-drop, now threaded).**
`handleGraphQL` passed only `{ request }` to `kernel.graphql`, dropping the
resolved `ExecutionContext` — the moment a real engine resolved objects
through ObjectQL it would have run context-less (security middleware falls
OPEN on a missing principal = full authority). The entry point now resolves
the caller identity even on the direct dispatcher-plugin route and even when
`requireAuth` is off, and threads it as `options.context`;
`IGraphQLService.execute` documents that implementations MUST forward it to
every data-engine call. Unit-proven; the authz conformance matrix pins the
threading (`graphql-identity-thread` row) so removing it goes STALE and
fails CI.

- **realtime (surface 2 — no per-recipient authz seam, posture registered).**
Delivery is a pure fan-out (subscriptions carry no principal,
`matchesSubscription` filters only by object+eventTypes, the engine
publishes the full `after` row), safe only while every subscriber is
server-internal. The posture is now registered as an `experimental` matrix
row (`realtime-delivery-authz`) stating the admission requirement
(per-recipient RLS/FLS/tenant re-check on delivery, or id-only payload +
client re-fetch), and transport TRIPWIRE probes turn any newly wired
WebSocket/SSE/subscribe/client transport into an UNCLASSIFIED surface → red
CI until the identity story ships with it. The `service-realtime` README —
which advertised `authorizeChannel`/`broadcastToUser`/presence auth that do
not exist — is rewritten to describe the real, trusted-internal-only
surface, and the contract docs carry the admission requirement at the seam.
2 changes: 2 additions & 0 deletions content/docs/protocol/kernel/realtime-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The **Real-Time Protocol** describes how live data synchronization is intended t

<Callout type="warn">
**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.

**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.
</Callout>

## Why Real-Time Matters
Expand Down
14 changes: 14 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
covers: ['data:hono-plugin.ts:POST /data/:object', 'data:hono-plugin.ts:GET /data/:object/:id', 'data:hono-plugin.ts:GET /data/:object'],
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.' },

// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
// admission). Neither surface is reachable by a client today; these rows
// register their identity posture NOW so the ratchet (see the probes +
// transport tripwires in authz-conformance.test.ts) blocks wiring a client
// transport without the identity story — in CI, not in an adversarial
// review after the fact.
{ id: 'graphql-identity-thread', summary: 'GraphQL entry point threads the caller identity to the engine (#2992 surface 1, ADR-0096 D1)', state: 'enforced',
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',
covers: ['graphql:http-dispatcher.ts:kernel.graphql(context-threaded)'],
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.' },
{ id: 'realtime-delivery-authz', summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)', state: 'experimental',
covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'],
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.' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },

Expand Down
75 changes: 75 additions & 0 deletions packages/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,60 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
re: /rawApp\.(get|post|put|patch|delete)\(\s*`\$\{prefix\}(\/data[^`]*)`/g,
key: (m) => `data:hono-plugin.ts:${m[1].toUpperCase()} ${m[2]}`,
},

// ── #2992 / ADR-0096 D4 — latent-surface identity pins ─────────────────
// GraphQL identity threading: the ONLY kernel.graphql(...) call site must
// carry `context:` in its options. If a refactor drops the threading the key
// vanishes → the `graphql-identity-thread` row's covers goes STALE → red CI.
{
file: 'packages/runtime/src/http-dispatcher.ts',
re: /kernel\.graphql\([^)]*\bcontext:/g,
key: () => 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)',
},
// Realtime delivery fan-out: pins the trusted-internal-only posture of the
// in-memory adapter's publish loop (`realtime-delivery-authz` row).
{
file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts',
re: /async\s+publish\s*\(/g,
key: () => 'realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)',
},

// ── #2992 transport TRIPWIRES — deliberately covered by NO row ──────────
// Delivery today is a pure fan-out with no per-recipient authorization
// (subscriptions carry no principal, payload is the full record), which is
// safe ONLY while every subscriber is server-internal. These patterns match
// nothing today; the moment someone wires an end-user realtime transport
// (WebSocket handshake, SSE, a client transport) a NEW key appears →
// UNCLASSIFIED surface → red CI with this checklist: add per-recipient
// RLS/FLS/tenant re-check on delivery (or switch to id-only payloads),
// THEN register the enforcement site in a matrix row covering the new key.
{
file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts',
re: /handleUpgrade\s*\(/g,
key: () => 'realtime:in-memory-realtime-adapter.ts:handleUpgrade(TRANSPORT-WIRED)',
},
{
file: 'packages/services/service-realtime/src/realtime-service-plugin.ts',
re: /handleUpgrade\s*\(|new\s+WebSocketServer|text\/event-stream/g,
key: () => 'realtime:realtime-service-plugin.ts:transport(TRANSPORT-WIRED)',
},
{
file: 'packages/runtime/src/http-dispatcher.ts',
re: /async\s+handle(Realtime|Upgrade|Subscribe)\w*\s*\(/g,
key: (m) => `realtime:http-dispatcher.ts:handle${m[1]}(TRANSPORT-WIRED)`,
},
{
file: 'packages/client/src/realtime-api.ts',
re: /new\s+WebSocket\b|new\s+EventSource\b/g,
key: () => 'realtime:client/realtime-api.ts:transport(TRANSPORT-WIRED)',
},
// packages/rest/src has ZERO realtime refs today (#2992) — a `/realtime`
// route literal appearing there is a subscribe endpoint. Same tripwire.
{
file: 'packages/rest/src/rest-server.ts',
re: /['"`][^'"`]*\/realtime[^'"`]*['"`]/g,
key: () => 'realtime:rest-server.ts:route(TRANSPORT-WIRED)',
},
];

/** Statically enumerate the anonymous-deny HTTP entry points from source. */
Expand Down Expand Up @@ -132,4 +186,25 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces()));
expect(problems.some((p) => /STALE covers/.test(p) && /handleRemovedThing/.test(p))).toBe(true);
});

// ── #2992 — the latent-surface pins bite too ──────────────────────────
it('(d) wiring a realtime transport (tripwire key appears) → UNCLASSIFIED surface failure (#2992)', () => {
const fake = 'realtime:in-memory-realtime-adapter.ts:handleUpgrade(TRANSPORT-WIRED)';
const problems = checkLedger(
AUTHZ_CONFORMANCE,
opts(() => new Set([...discoverAnonymousDenySurfaces(), fake])),
);
expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fake))).toBe(true);
});

it('(e) dropping the GraphQL context-thread → STALE covers failure (#2992)', () => {
const threaded = 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)';
// Baseline sanity: the threading is discovered from source today.
expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true);
const problems = checkLedger(
AUTHZ_CONFORMANCE,
opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== threaded))),
);
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true);
});
});
56 changes: 56 additions & 0 deletions packages/runtime/src/http-dispatcher.requireauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,62 @@ describe('HttpDispatcher requireAuth gate — GraphQL (handleGraphQL)', () => {
});
});

// #2992 / ADR-0096 D1 — the GraphQL entry point must THREAD the caller's
// identity to the engine, not just gate anonymity. kernel.graphql is a stub
// surface today (never assigned in the monorepo), but the moment a real
// engine lands it resolves objects through ObjectQL, whose security
// middleware falls OPEN on a missing principal — so the entry point passes
// the resolved ExecutionContext as `options.context` (the same key the REST
// callData path threads). Dropping it also goes STALE in the authz
// conformance matrix (dogfood/test/authz-conformance.test.ts).
describe('HttpDispatcher identity threading — GraphQL (handleGraphQL, #2992)', () => {
const gqlBody = { query: '{ __typename }', variables: { a: 1 } };

const makeGraphQLKernel = (calls: any[]) =>
makeKernel({
graphql: (query: string, variables: any, options: any) => {
calls.push({ query, variables, options });
return { data: {} };
},
});

it('threads the resolved ExecutionContext as options.context', async () => {
const calls: any[] = [];
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true });
await d.handleGraphQL(gqlBody, authed);
expect(calls).toHaveLength(1);
expect(calls[0].query).toBe(gqlBody.query);
expect(calls[0].variables).toEqual(gqlBody.variables);
expect(calls[0].options.context).toBe(authed.executionContext);
});

it('threads a system context unchanged', async () => {
const calls: any[] = [];
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true });
await d.handleGraphQL(gqlBody, system);
expect(calls[0].options.context).toBe(system.executionContext);
});

it('threads the caller identity even when requireAuth is OFF (an authenticated caller on an open deployment still runs under their own authority)', async () => {
const calls: any[] = [];
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false });
await d.handleGraphQL(gqlBody, authed);
expect(calls[0].options.context).toBe(authed.executionContext);
});

it('an anonymous caller on an open deployment carries NO authority (explicit guest principal or nothing — never a forged user/system identity)', async () => {
const calls: any[] = [];
const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false });
// Fresh context object: handleGraphQL caches the resolved identity on it.
await d.handleGraphQL(gqlBody, { request: {}, executionContext: undefined } as any);
const threaded = calls[0].options.context;
// The resolver yields an explicit guest principal (mirroring dispatch());
// whatever is threaded must carry no user and no system authority.
expect(threaded?.userId).toBeUndefined();
expect(threaded?.isSystem ?? false).toBe(false);
});
});

describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => {
it('401s an anonymous caller when requireAuth is on', async () => {
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
Expand Down
24 changes: 19 additions & 5 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1631,9 +1631,13 @@ export class HttpDispatcher {
//
// The dispatcher-plugin's direct `/graphql` route calls us WITHOUT
// resolving identity first (unlike `dispatch()`, which populates
// `context.executionContext`), so resolve it here when absent.
// `context.executionContext`), so resolve it here when absent —
// REGARDLESS of `requireAuth`: the resolved identity is also what we
// thread to the engine below (#2992), and an authenticated caller on a
// `requireAuth: false` deployment must still run under their own
// authority, not context-less.
let ec: any = context.executionContext;
if (this.requireAuth && !ec) {
if (!ec) {
ec = await this.resolveRequestExecutionContext(context);
if (ec) context.executionContext = ec;
}
Expand All @@ -1652,8 +1656,16 @@ export class HttpDispatcher {
throw { statusCode: 501, message: 'GraphQL service not available' };
}

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

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