@adaptic/backend-legacy 0.0.1008 → 0.0.1010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * GraphQL context-layer authentication SHADOW observer.
3
+ *
4
+ * This is the transport/context-level counterpart to the resolver-level
5
+ * {@link ../auth/cortex-auth-checker.cortexAuthChecker}. The two observe DIFFERENT
6
+ * populations and are complementary:
7
+ *
8
+ * - `cortexAuthChecker` runs ONLY for `@Authorized()`-decorated fields (today:
9
+ * the 5 investor-relations models + delete mutations). It cannot see an
10
+ * unauthenticated request to any of the hundreds of auto-generated,
11
+ * undecorated CRUD fields — which is the overwhelming majority of live
12
+ * traffic (the platform, the account-audit scripts, and the engine all reach
13
+ * undecorated resolvers).
14
+ * - THIS observer runs in the `/graphql` HTTP + WebSocket `context()` callback,
15
+ * BEFORE any resolver, on EVERY request. It records whether a request arrived
16
+ * with a verified principal, with no principal, or with an invalid token —
17
+ * the true, complete denominator+numerator the enforcement decision needs.
18
+ *
19
+ * WHY (audit context): the `/graphql` `context()` currently lets a request with
20
+ * NO bearer token fall through as `{ principal: null }` and ALLOWS it. Requiring
21
+ * a principal at this layer (rejecting `null`) is the change that would break the
22
+ * entire product today (cookie-authenticated platform, token-less audit scripts,
23
+ * the engine's `SERVER_AUTH_TOKEN` gate). Before that enforcement can ever be
24
+ * flipped, we must first MEASURE, in production, exactly who reaches `/graphql`
25
+ * without a verified principal. This module provides that measurement WITHOUT
26
+ * changing behaviour: it counts and (throttled) logs would-denies and always
27
+ * returns control to the caller.
28
+ *
29
+ * SAFETY CONTRACT — observe-only, never blocks:
30
+ * - The Prometheus counter {@link graphqlAuthContextEvaluationsTotal} increments
31
+ * on every context evaluation, labelled by transport + outcome. It is the
32
+ * primary quantitative signal and carries only BOUNDED labels (never the
33
+ * attacker-controllable operation name / origin / IP).
34
+ * - For the `no_principal` outcome — the would-deny — a structured identity log
35
+ * is emitted, THROTTLED to the first occurrence of each
36
+ * `(transport, operationName, origin)` per dedup window. Unthrottled logging
37
+ * of every unauthenticated request would flood Cloud Logging and bury real
38
+ * warnings (audit B01-backend-legacy-10); the counter carries the per-request
39
+ * cardinality instead.
40
+ * - The dedup store is capped and rotates novel keys into a shared overflow
41
+ * bucket so an attacker rotating spoofed origins/operation names cannot grow
42
+ * memory without bound (audit B01-backend-legacy-12).
43
+ *
44
+ * @see src/auth/cortex-auth-checker.ts for the resolver-level shadow checker.
45
+ * @see docs/security/2026-08-23-graphql-auth-enforcement-runbook.md for the
46
+ * staged enforcement plan this observer feeds.
47
+ */
48
+ import { Counter, Gauge } from 'prom-client';
49
+ /** The GraphQL transport a context evaluation ran on. */
50
+ export type GraphqlTransport = 'http' | 'ws';
51
+ /**
52
+ * Outcome of a `/graphql` `context()` evaluation.
53
+ *
54
+ * - `authenticated` — a verified {@link ../auth/token-verifier.BackendPrincipal}
55
+ * was attached.
56
+ * - `no_principal` — no bearer token was presented; the request falls through
57
+ * with `principal: null`. This is the WOULD-DENY case under principal-required
58
+ * enforcement — the sole behaviour the enforcement flip changes.
59
+ * - `invalid_token` — a token was presented but failed verification. This is
60
+ * ALREADY rejected today (HTTP 401 / WS close); recorded here only to complete
61
+ * the denominator.
62
+ */
63
+ export type AuthContextOutcome = 'authenticated' | 'no_principal' | 'invalid_token';
64
+ /**
65
+ * Caller identity captured for a would-deny (`no_principal`) request. Every field
66
+ * is best-effort and may be absent (server-to-server callers send no `origin`;
67
+ * the WS operation name is not always known at context time). Nothing here is
68
+ * ever used as a Prometheus label — only in the throttled structured log.
69
+ */
70
+ export interface ShadowAuthIdentity {
71
+ /** Transport the request arrived on. */
72
+ transport: GraphqlTransport;
73
+ /** GraphQL operation name, when resolvable from the request body / args. */
74
+ operationName?: string;
75
+ /** `Origin` header — distinguishes browser callers (platform) from scripts. */
76
+ origin?: string;
77
+ /** Client IP, as resolved by the caller (Express `req.ip` honours trust-proxy). */
78
+ ip?: string;
79
+ /** `User-Agent` header, when present. */
80
+ userAgent?: string;
81
+ /**
82
+ * Whether an `Authorization` header was present at all. `true` here with a
83
+ * `no_principal` outcome means the header was present but not a `Bearer <token>`
84
+ * (or an empty bearer) — a distinct, actionable misconfiguration.
85
+ */
86
+ authHeaderPresent: boolean;
87
+ }
88
+ /**
89
+ * Counts EVERY `/graphql` context evaluation, labelled by {@link GraphqlTransport}
90
+ * and {@link AuthContextOutcome}. This single series is both the denominator
91
+ * (total requests seen) and the numerator (`outcome="no_principal"` = would-denies).
92
+ *
93
+ * Graduation rule for the runbook: the enforcement flip is safe to consider only
94
+ * once `outcome="no_principal"` has been driven to (effectively) zero for the
95
+ * transport being enforced, sustained across a full trading week — i.e. every
96
+ * legitimate caller has been migrated to presenting a verified principal.
97
+ *
98
+ * Labels are deliberately BOUNDED (2 transports x 3 outcomes = 6 series). The
99
+ * high-cardinality identity (operation name, origin, IP) lives only in the
100
+ * throttled log, never as a metric label — an unauthenticated caller controls
101
+ * those strings and could otherwise explode metric cardinality.
102
+ */
103
+ export declare const graphqlAuthContextEvaluationsTotal: Counter<"outcome" | "transport">;
104
+ /**
105
+ * Current tracked-key cardinality of the throttled-log dedup store. Bounds
106
+ * visibility into the store ahead of the {@link MAX_TRACKED_SHADOW_KEYS} cap
107
+ * (audit B01-backend-legacy-12).
108
+ */
109
+ export declare const graphqlAuthShadowTrackedKeys: Gauge<string>;
110
+ /**
111
+ * Environment variable that DISABLES the throttled identity log. Absent (the
112
+ * default) keeps logging ON. Set to `true`/`1` to silence the per-caller log
113
+ * lines if they ever become operationally noisy — the {@link
114
+ * graphqlAuthContextEvaluationsTotal} counter continues unaffected, so the
115
+ * quantitative signal is never lost.
116
+ */
117
+ export declare const GRAPHQL_AUTH_SHADOW_LOG_DISABLED_ENV = "GRAPHQL_AUTH_SHADOW_LOG_DISABLED";
118
+ /**
119
+ * Whether the throttled identity log is disabled. Read fresh each call (a cheap
120
+ * env read) so it can be toggled operationally without a restart — matching the
121
+ * shadow-flag convention used by the AuthChecker and rate limiter.
122
+ *
123
+ * @param env - Environment source (defaults to `process.env`); injectable for tests.
124
+ * @returns `true` only when the flag is explicitly `true`/`1`.
125
+ */
126
+ export declare function isShadowAuthLoggingDisabled(env?: Record<string, string | undefined>): boolean;
127
+ /**
128
+ * Dedup window for the identity log: at most one line per distinct
129
+ * `(transport, operationName, origin)` key per window. Sized long (10 min) so a
130
+ * high-volume unauthenticated caller (e.g. the whole platform) contributes ~one
131
+ * line per window rather than one per request.
132
+ */
133
+ export declare const SHADOW_LOG_DEDUP_WINDOW_MS: number;
134
+ /**
135
+ * Normalise a raw header value (`string | string[] | undefined`, arriving typed
136
+ * as `unknown`) to a single string. Node folds repeated headers into an array;
137
+ * we keep the first value.
138
+ *
139
+ * @param value - Raw header value.
140
+ * @returns The string value, or `undefined` when absent/unusable.
141
+ */
142
+ export declare function headerToString(value: unknown): string | undefined;
143
+ /**
144
+ * Extract a GraphQL operation name from a parsed HTTP request body.
145
+ *
146
+ * @param body - The JSON-parsed `/graphql` POST body (typed `unknown`).
147
+ * @returns The `operationName`, or `undefined` when absent/empty.
148
+ */
149
+ export declare function extractOperationName(body: unknown): string | undefined;
150
+ /**
151
+ * Extract a GraphQL operation name from graphql-ws `ExecutionArgs`.
152
+ *
153
+ * @param args - The third argument to the graphql-ws `context` callback (typed `unknown`).
154
+ * @returns The `operationName`, or `undefined` when absent/empty.
155
+ */
156
+ export declare function extractOperationNameFromArgs(args: unknown): string | undefined;
157
+ /**
158
+ * Extract `origin` / `userAgent` / `ip` from the graphql-ws `ctx.extra` object
159
+ * (`{ request, socket }` for the `ws` integration), tolerating any shape.
160
+ *
161
+ * @param extra - The graphql-ws context `extra` (typed `unknown`).
162
+ * @returns Best-effort header identity for the WebSocket upgrade request.
163
+ */
164
+ export declare function extractHeaderIdentityFromWsExtra(extra: unknown): {
165
+ origin?: string;
166
+ userAgent?: string;
167
+ ip?: string;
168
+ };
169
+ /**
170
+ * Record a context evaluation outcome on {@link graphqlAuthContextEvaluationsTotal}.
171
+ * Use for the `authenticated` and `invalid_token` outcomes; the `no_principal`
172
+ * outcome is recorded (with identity logging) by {@link recordShadowAuthMiss}.
173
+ *
174
+ * @param transport - The transport the evaluation ran on.
175
+ * @param outcome - The evaluation outcome.
176
+ */
177
+ export declare function recordAuthContextOutcome(transport: GraphqlTransport, outcome: AuthContextOutcome): void;
178
+ /**
179
+ * Record a would-deny (`no_principal`) request: increment the counter and, unless
180
+ * logging is disabled, emit a throttled structured identity log. NEVER blocks and
181
+ * NEVER throws — safe to call on the hot path of the `context()` callback.
182
+ *
183
+ * @param identity - Best-effort caller identity for the unauthenticated request.
184
+ */
185
+ export declare function recordShadowAuthMiss(identity: ShadowAuthIdentity): void;
186
+ /**
187
+ * Test-only reset of the throttled-log dedup store. Mirrors the token-verifier's
188
+ * `_resetGoogleAudienceCacheForTests` convention so tests can assert first-in-window
189
+ * emission deterministically without waiting out a real window.
190
+ *
191
+ * @internal
192
+ */
193
+ export declare function _resetShadowAuthStateForTests(): void;
194
+ //# sourceMappingURL=graphql-auth-shadow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graphql-auth-shadow.d.ts","sourceRoot":"","sources":["../../../src/auth/graphql-auth-shadow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAQ7C,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,kBAAkB,GAC1B,eAAe,GACf,cAAc,GACd,eAAe,CAAC;AAEpB;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,SAAS,EAAE,gBAAgB,CAAC;IAC5B,4EAA4E;IAC5E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAMD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kCAAkC,kCAK7C,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,eAIvC,CAAC;AAMH;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,qCACb,CAAC;AAErC;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACpD,OAAO,CAKT;AAMD;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,QAAiB,CAAC;AA+EzD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAItE;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,OAAO,GACZ,MAAM,GAAG,SAAS,CAIpB;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAqBA;AAaD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE,kBAAkB,GAC1B,IAAI,CAEN;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CA4BvE;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graphql-auth-shadow.js","sourceRoot":"","sources":["../../../src/auth/graphql-auth-shadow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,305 @@
1
+ /**
2
+ * GraphQL context-layer authentication SHADOW observer.
3
+ *
4
+ * This is the transport/context-level counterpart to the resolver-level
5
+ * {@link ../auth/cortex-auth-checker.cortexAuthChecker}. The two observe DIFFERENT
6
+ * populations and are complementary:
7
+ *
8
+ * - `cortexAuthChecker` runs ONLY for `@Authorized()`-decorated fields (today:
9
+ * the 5 investor-relations models + delete mutations). It cannot see an
10
+ * unauthenticated request to any of the hundreds of auto-generated,
11
+ * undecorated CRUD fields — which is the overwhelming majority of live
12
+ * traffic (the platform, the account-audit scripts, and the engine all reach
13
+ * undecorated resolvers).
14
+ * - THIS observer runs in the `/graphql` HTTP + WebSocket `context()` callback,
15
+ * BEFORE any resolver, on EVERY request. It records whether a request arrived
16
+ * with a verified principal, with no principal, or with an invalid token —
17
+ * the true, complete denominator+numerator the enforcement decision needs.
18
+ *
19
+ * WHY (audit context): the `/graphql` `context()` currently lets a request with
20
+ * NO bearer token fall through as `{ principal: null }` and ALLOWS it. Requiring
21
+ * a principal at this layer (rejecting `null`) is the change that would break the
22
+ * entire product today (cookie-authenticated platform, token-less audit scripts,
23
+ * the engine's `SERVER_AUTH_TOKEN` gate). Before that enforcement can ever be
24
+ * flipped, we must first MEASURE, in production, exactly who reaches `/graphql`
25
+ * without a verified principal. This module provides that measurement WITHOUT
26
+ * changing behaviour: it counts and (throttled) logs would-denies and always
27
+ * returns control to the caller.
28
+ *
29
+ * SAFETY CONTRACT — observe-only, never blocks:
30
+ * - The Prometheus counter {@link graphqlAuthContextEvaluationsTotal} increments
31
+ * on every context evaluation, labelled by transport + outcome. It is the
32
+ * primary quantitative signal and carries only BOUNDED labels (never the
33
+ * attacker-controllable operation name / origin / IP).
34
+ * - For the `no_principal` outcome — the would-deny — a structured identity log
35
+ * is emitted, THROTTLED to the first occurrence of each
36
+ * `(transport, operationName, origin)` per dedup window. Unthrottled logging
37
+ * of every unauthenticated request would flood Cloud Logging and bury real
38
+ * warnings (audit B01-backend-legacy-10); the counter carries the per-request
39
+ * cardinality instead.
40
+ * - The dedup store is capped and rotates novel keys into a shared overflow
41
+ * bucket so an attacker rotating spoofed origins/operation names cannot grow
42
+ * memory without bound (audit B01-backend-legacy-12).
43
+ *
44
+ * @see src/auth/cortex-auth-checker.ts for the resolver-level shadow checker.
45
+ * @see docs/security/2026-08-23-graphql-auth-enforcement-runbook.md for the
46
+ * staged enforcement plan this observer feeds.
47
+ */
48
+ import { Counter, Gauge } from 'prom-client';
49
+ import { metricsRegistry } from '../config/metrics.mjs';
50
+ import { logger } from '../utils/logger.mjs';
51
+ // -----------------------------------------------------------------------------
52
+ // Metrics
53
+ // -----------------------------------------------------------------------------
54
+ /**
55
+ * Counts EVERY `/graphql` context evaluation, labelled by {@link GraphqlTransport}
56
+ * and {@link AuthContextOutcome}. This single series is both the denominator
57
+ * (total requests seen) and the numerator (`outcome="no_principal"` = would-denies).
58
+ *
59
+ * Graduation rule for the runbook: the enforcement flip is safe to consider only
60
+ * once `outcome="no_principal"` has been driven to (effectively) zero for the
61
+ * transport being enforced, sustained across a full trading week — i.e. every
62
+ * legitimate caller has been migrated to presenting a verified principal.
63
+ *
64
+ * Labels are deliberately BOUNDED (2 transports x 3 outcomes = 6 series). The
65
+ * high-cardinality identity (operation name, origin, IP) lives only in the
66
+ * throttled log, never as a metric label — an unauthenticated caller controls
67
+ * those strings and could otherwise explode metric cardinality.
68
+ */
69
+ export const graphqlAuthContextEvaluationsTotal = new Counter({
70
+ name: 'graphql_auth_context_evaluations_total',
71
+ help: 'GraphQL /graphql context evaluations by transport and outcome (outcome="no_principal" is the shadow would-deny; observe-only, never blocks)',
72
+ labelNames: ['transport', 'outcome'],
73
+ registers: [metricsRegistry],
74
+ });
75
+ /**
76
+ * Current tracked-key cardinality of the throttled-log dedup store. Bounds
77
+ * visibility into the store ahead of the {@link MAX_TRACKED_SHADOW_KEYS} cap
78
+ * (audit B01-backend-legacy-12).
79
+ */
80
+ export const graphqlAuthShadowTrackedKeys = new Gauge({
81
+ name: 'graphql_auth_shadow_tracked_keys',
82
+ help: 'Current distinct (transport, operationName, origin) keys tracked by the shadow-auth log throttle',
83
+ registers: [metricsRegistry],
84
+ });
85
+ // -----------------------------------------------------------------------------
86
+ // Logging-enable flag (counter is always on; this only gates the detail log)
87
+ // -----------------------------------------------------------------------------
88
+ /**
89
+ * Environment variable that DISABLES the throttled identity log. Absent (the
90
+ * default) keeps logging ON. Set to `true`/`1` to silence the per-caller log
91
+ * lines if they ever become operationally noisy — the {@link
92
+ * graphqlAuthContextEvaluationsTotal} counter continues unaffected, so the
93
+ * quantitative signal is never lost.
94
+ */
95
+ export const GRAPHQL_AUTH_SHADOW_LOG_DISABLED_ENV = 'GRAPHQL_AUTH_SHADOW_LOG_DISABLED';
96
+ /**
97
+ * Whether the throttled identity log is disabled. Read fresh each call (a cheap
98
+ * env read) so it can be toggled operationally without a restart — matching the
99
+ * shadow-flag convention used by the AuthChecker and rate limiter.
100
+ *
101
+ * @param env - Environment source (defaults to `process.env`); injectable for tests.
102
+ * @returns `true` only when the flag is explicitly `true`/`1`.
103
+ */
104
+ export function isShadowAuthLoggingDisabled(env = process.env) {
105
+ const raw = (env[GRAPHQL_AUTH_SHADOW_LOG_DISABLED_ENV] ?? '')
106
+ .trim()
107
+ .toLowerCase();
108
+ return raw === 'true' || raw === '1';
109
+ }
110
+ // -----------------------------------------------------------------------------
111
+ // Throttled-log dedup store (mirrors the rate-limiter's capped store pattern)
112
+ // -----------------------------------------------------------------------------
113
+ /**
114
+ * Dedup window for the identity log: at most one line per distinct
115
+ * `(transport, operationName, origin)` key per window. Sized long (10 min) so a
116
+ * high-volume unauthenticated caller (e.g. the whole platform) contributes ~one
117
+ * line per window rather than one per request.
118
+ */
119
+ export const SHADOW_LOG_DEDUP_WINDOW_MS = 10 * 60 * 1000;
120
+ /** Sweep cadence for expired dedup windows. */
121
+ const STORE_SWEEP_INTERVAL_MS = 60_000;
122
+ /**
123
+ * Cap on distinct dedup keys. Past the cap, novel keys aggregate into a single
124
+ * shared overflow bucket instead of allocating — bounding memory under an
125
+ * attacker rotating spoofed origins / operation names (audit B01-backend-legacy-12).
126
+ */
127
+ const MAX_TRACKED_SHADOW_KEYS = 10_000;
128
+ /** Dedup key used once the store cardinality cap is reached. */
129
+ const OVERFLOW_KEY = 'overflow';
130
+ /** Max characters retained from a caller-supplied string used in a dedup key. */
131
+ const MAX_KEY_SEGMENT_LEN = 120;
132
+ /** Max characters retained from a `User-Agent` in the log body. */
133
+ const MAX_UA_LEN = 200;
134
+ const shadowLogStore = new Map();
135
+ // Sweep expired windows every minute. `unref()` so importing this module (tests,
136
+ // one-off scripts) never keeps the process alive on account of the sweeper.
137
+ setInterval(() => {
138
+ const now = Date.now();
139
+ for (const [key, entry] of shadowLogStore) {
140
+ if (entry.windowResetAt < now) {
141
+ shadowLogStore.delete(key);
142
+ }
143
+ }
144
+ graphqlAuthShadowTrackedKeys.set(shadowLogStore.size);
145
+ }, STORE_SWEEP_INTERVAL_MS).unref();
146
+ /**
147
+ * Decide whether to emit an identity log line for `key` now: emit on the first
148
+ * occurrence within the current dedup window, then suppress until the window
149
+ * rolls over. Applies the cardinality cap + overflow bucket.
150
+ *
151
+ * @param key - The `(transport, operationName, origin)` dedup key.
152
+ * @returns `true` when this is the first sighting of the key in the window.
153
+ */
154
+ function shouldEmitIdentityLog(key) {
155
+ const now = Date.now();
156
+ let storeKey = key;
157
+ // Cardinality cap: fold novel keys into the shared overflow bucket rather than
158
+ // growing without bound.
159
+ if (!shadowLogStore.has(storeKey) &&
160
+ shadowLogStore.size >= MAX_TRACKED_SHADOW_KEYS) {
161
+ storeKey = OVERFLOW_KEY;
162
+ }
163
+ const existing = shadowLogStore.get(storeKey);
164
+ if (!existing || existing.windowResetAt < now) {
165
+ shadowLogStore.set(storeKey, {
166
+ count: 1,
167
+ windowResetAt: now + SHADOW_LOG_DEDUP_WINDOW_MS,
168
+ });
169
+ return true;
170
+ }
171
+ existing.count += 1;
172
+ return false;
173
+ }
174
+ // -----------------------------------------------------------------------------
175
+ // Extraction helpers (accept `unknown` so callers pass framework objects without
176
+ // leaking `any` or coupling this module to Express / graphql-ws types)
177
+ // -----------------------------------------------------------------------------
178
+ /**
179
+ * Normalise a raw header value (`string | string[] | undefined`, arriving typed
180
+ * as `unknown`) to a single string. Node folds repeated headers into an array;
181
+ * we keep the first value.
182
+ *
183
+ * @param value - Raw header value.
184
+ * @returns The string value, or `undefined` when absent/unusable.
185
+ */
186
+ export function headerToString(value) {
187
+ if (typeof value === 'string')
188
+ return value;
189
+ if (Array.isArray(value) && typeof value[0] === 'string')
190
+ return value[0];
191
+ return undefined;
192
+ }
193
+ /**
194
+ * Extract a GraphQL operation name from a parsed HTTP request body.
195
+ *
196
+ * @param body - The JSON-parsed `/graphql` POST body (typed `unknown`).
197
+ * @returns The `operationName`, or `undefined` when absent/empty.
198
+ */
199
+ export function extractOperationName(body) {
200
+ if (typeof body !== 'object' || body === null)
201
+ return undefined;
202
+ const op = body.operationName;
203
+ return typeof op === 'string' && op.length > 0 ? op : undefined;
204
+ }
205
+ /**
206
+ * Extract a GraphQL operation name from graphql-ws `ExecutionArgs`.
207
+ *
208
+ * @param args - The third argument to the graphql-ws `context` callback (typed `unknown`).
209
+ * @returns The `operationName`, or `undefined` when absent/empty.
210
+ */
211
+ export function extractOperationNameFromArgs(args) {
212
+ if (typeof args !== 'object' || args === null)
213
+ return undefined;
214
+ const op = args.operationName;
215
+ return typeof op === 'string' && op.length > 0 ? op : undefined;
216
+ }
217
+ /**
218
+ * Extract `origin` / `userAgent` / `ip` from the graphql-ws `ctx.extra` object
219
+ * (`{ request, socket }` for the `ws` integration), tolerating any shape.
220
+ *
221
+ * @param extra - The graphql-ws context `extra` (typed `unknown`).
222
+ * @returns Best-effort header identity for the WebSocket upgrade request.
223
+ */
224
+ export function extractHeaderIdentityFromWsExtra(extra) {
225
+ if (typeof extra !== 'object' || extra === null)
226
+ return {};
227
+ const request = extra.request;
228
+ if (typeof request !== 'object' || request === null)
229
+ return {};
230
+ const headers = request.headers;
231
+ const socket = request.socket;
232
+ const headerBag = typeof headers === 'object' && headers !== null
233
+ ? headers
234
+ : {};
235
+ const remoteAddress = typeof socket === 'object' && socket !== null
236
+ ? socket.remoteAddress
237
+ : undefined;
238
+ return {
239
+ origin: headerToString(headerBag['origin']),
240
+ userAgent: headerToString(headerBag['user-agent']),
241
+ ip: typeof remoteAddress === 'string' ? remoteAddress : undefined,
242
+ };
243
+ }
244
+ /** Trim + length-cap a caller-supplied string for safe use in a dedup key. */
245
+ function capSegment(value, max) {
246
+ if (!value)
247
+ return '';
248
+ const trimmed = value.trim();
249
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
250
+ }
251
+ // -----------------------------------------------------------------------------
252
+ // Recording entry points (called from server.ts context callbacks)
253
+ // -----------------------------------------------------------------------------
254
+ /**
255
+ * Record a context evaluation outcome on {@link graphqlAuthContextEvaluationsTotal}.
256
+ * Use for the `authenticated` and `invalid_token` outcomes; the `no_principal`
257
+ * outcome is recorded (with identity logging) by {@link recordShadowAuthMiss}.
258
+ *
259
+ * @param transport - The transport the evaluation ran on.
260
+ * @param outcome - The evaluation outcome.
261
+ */
262
+ export function recordAuthContextOutcome(transport, outcome) {
263
+ graphqlAuthContextEvaluationsTotal.inc({ transport, outcome });
264
+ }
265
+ /**
266
+ * Record a would-deny (`no_principal`) request: increment the counter and, unless
267
+ * logging is disabled, emit a throttled structured identity log. NEVER blocks and
268
+ * NEVER throws — safe to call on the hot path of the `context()` callback.
269
+ *
270
+ * @param identity - Best-effort caller identity for the unauthenticated request.
271
+ */
272
+ export function recordShadowAuthMiss(identity) {
273
+ graphqlAuthContextEvaluationsTotal.inc({
274
+ transport: identity.transport,
275
+ outcome: 'no_principal',
276
+ });
277
+ if (isShadowAuthLoggingDisabled())
278
+ return;
279
+ const operationName = capSegment(identity.operationName, MAX_KEY_SEGMENT_LEN) || '<unnamed>';
280
+ const origin = capSegment(identity.origin, MAX_KEY_SEGMENT_LEN) || '<none>';
281
+ const key = `${identity.transport}|${operationName}|${origin}`;
282
+ if (!shouldEmitIdentityLog(key))
283
+ return;
284
+ logger.warn('[graphql-auth-shadow] would-deny: unauthenticated /graphql request (allowing — no verified principal)', {
285
+ transport: identity.transport,
286
+ operationName,
287
+ origin,
288
+ ip: identity.ip,
289
+ userAgent: capSegment(identity.userAgent, MAX_UA_LEN) || undefined,
290
+ authHeaderPresent: identity.authHeaderPresent,
291
+ dedupWindowMs: SHADOW_LOG_DEDUP_WINDOW_MS,
292
+ note: 'First occurrence of this (transport, operationName, origin) in the current dedup window. Every occurrence is counted on graphql_auth_context_evaluations_total{outcome="no_principal"}.',
293
+ });
294
+ }
295
+ /**
296
+ * Test-only reset of the throttled-log dedup store. Mirrors the token-verifier's
297
+ * `_resetGoogleAudienceCacheForTests` convention so tests can assert first-in-window
298
+ * emission deterministically without waiting out a real window.
299
+ *
300
+ * @internal
301
+ */
302
+ export function _resetShadowAuthStateForTests() {
303
+ shadowLogStore.clear();
304
+ }
305
+ //# sourceMappingURL=graphql-auth-shadow.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/backend-legacy",
3
- "version": "0.0.1008",
3
+ "version": "0.0.1010",
4
4
  "description": "Backend executable CRUD functions with dynamic variables construction, and type definitions for the Adaptic AI platform.",
5
5
  "type": "module",
6
6
  "types": "index.d.ts",
package/server.cjs CHANGED
@@ -73,6 +73,7 @@ const prismaClient_1 = __importStar(require("./prismaClient.cjs"));
73
73
  const health_1 = require("./health.cjs");
74
74
  const logger_1 = require("./utils/logger.cjs");
75
75
  const token_verifier_1 = require("./auth/token-verifier.cjs");
76
+ const graphql_auth_shadow_1 = require("./auth/graphql-auth-shadow.cjs");
76
77
  /**
77
78
  * Adapt a verified `BackendPrincipal` to the legacy `user` context shape used
78
79
  * by downstream resolvers and audit plugins (`{ sub, role, roles? }`).
@@ -206,7 +207,9 @@ const startServer = async () => {
206
207
  // No-op when metrics are disabled.
207
208
  (0, metrics_1.createMetricsPlugin)(),
208
209
  ],
209
- ...(apqCache && (0, persisted_queries_1.isAPQEnabled)() ? { persistedQueries: { cache: apqCache } } : {}),
210
+ ...(apqCache && (0, persisted_queries_1.isAPQEnabled)()
211
+ ? { persistedQueries: { cache: apqCache } }
212
+ : {}),
210
213
  formatError: (err) => {
211
214
  var _a, _b;
212
215
  const message = err.message || '';
@@ -319,6 +322,21 @@ const startServer = async () => {
319
322
  // that requires a principal; this contract preserves the current
320
323
  // unauthenticated-public-query path until P0-001 lands.
321
324
  if (!token) {
325
+ // Shadow-observe the would-deny WITHOUT blocking: this null-principal
326
+ // request is exactly what a principal-required enforcement flip would
327
+ // reject. Counting + (throttled) logging it here is how we measure the
328
+ // enforcement blast radius in production before ever flipping it.
329
+ // See src/auth/graphql-auth-shadow.ts and
330
+ // docs/security/2026-08-23-graphql-auth-enforcement-runbook.md.
331
+ const rawBody = req.body;
332
+ (0, graphql_auth_shadow_1.recordShadowAuthMiss)({
333
+ transport: 'http',
334
+ operationName: (0, graphql_auth_shadow_1.extractOperationName)(rawBody),
335
+ origin: (0, graphql_auth_shadow_1.headerToString)(req.headers.origin),
336
+ ip: req.ip,
337
+ userAgent: (0, graphql_auth_shadow_1.headerToString)(req.headers['user-agent']),
338
+ authHeaderPresent: authHeader.length > 0,
339
+ });
322
340
  return { prisma: global.prisma, req, user: null, principal: null };
323
341
  }
324
342
  // Verify the bearer token through the SINGLE typed entry point. There
@@ -326,6 +344,7 @@ const startServer = async () => {
326
344
  // downgrade to an unverified principal on failure.
327
345
  try {
328
346
  const principal = await (0, token_verifier_1.verifyBackendToken)(token);
347
+ (0, graphql_auth_shadow_1.recordAuthContextOutcome)('http', 'authenticated');
329
348
  return {
330
349
  prisma: global.prisma,
331
350
  req,
@@ -338,6 +357,9 @@ const startServer = async () => {
338
357
  }
339
358
  catch (e) {
340
359
  const reason = e instanceof token_verifier_1.AuthError ? e.reason : 'bad_signature';
360
+ // Already rejected today (HTTP 401). Counted only to complete the
361
+ // shadow denominator alongside authenticated / no_principal outcomes.
362
+ (0, graphql_auth_shadow_1.recordAuthContextOutcome)('http', 'invalid_token');
341
363
  logger_1.logger.warn('GraphQL HTTP auth rejected', { reason });
342
364
  // Throw `UNAUTHENTICATED` so Apollo's HTTP transport returns a
343
365
  // GraphQL-shaped error response. The `formatError` hook above
@@ -383,7 +405,7 @@ const startServer = async () => {
383
405
  });
384
406
  (0, ws_2.useServer)({
385
407
  schema,
386
- context: async (ctx, _msg, _args) => {
408
+ context: async (ctx, _msg, args) => {
387
409
  var _a;
388
410
  // Ensure we're using the global prisma instance for WebSocket connections too
389
411
  if (!global.prisma) {
@@ -399,6 +421,19 @@ const startServer = async () => {
399
421
  // landing in CORTEX-P0-001 will reject any subscription that requires
400
422
  // a principal. Until then, public subscriptions continue to work.
401
423
  if (!token) {
424
+ // Shadow-observe the would-deny WITHOUT blocking — the WebSocket
425
+ // parallel of the HTTP path. Operation name is best-effort from
426
+ // ExecutionArgs; origin / user-agent / IP come from the upgrade
427
+ // request carried on ctx.extra.
428
+ const wsIdentity = (0, graphql_auth_shadow_1.extractHeaderIdentityFromWsExtra)(ctx.extra);
429
+ (0, graphql_auth_shadow_1.recordShadowAuthMiss)({
430
+ transport: 'ws',
431
+ operationName: (0, graphql_auth_shadow_1.extractOperationNameFromArgs)(args),
432
+ origin: wsIdentity.origin,
433
+ ip: wsIdentity.ip,
434
+ userAgent: wsIdentity.userAgent,
435
+ authHeaderPresent: authHeader.length > 0,
436
+ });
402
437
  return { prisma: global.prisma, user: null, principal: null };
403
438
  }
404
439
  // Verify the bearer token via the single typed entry point.
@@ -408,6 +443,7 @@ const startServer = async () => {
408
443
  // to an unauthenticated socket.
409
444
  try {
410
445
  const principal = await (0, token_verifier_1.verifyBackendToken)(token);
446
+ (0, graphql_auth_shadow_1.recordAuthContextOutcome)('ws', 'authenticated');
411
447
  return {
412
448
  prisma: global.prisma,
413
449
  user: principalToUser(principal),
@@ -416,6 +452,9 @@ const startServer = async () => {
416
452
  }
417
453
  catch (e) {
418
454
  const reason = e instanceof token_verifier_1.AuthError ? e.reason : 'bad_signature';
455
+ // Already rejected today (connection closed). Counted only to complete
456
+ // the shadow denominator alongside authenticated / no_principal.
457
+ (0, graphql_auth_shadow_1.recordAuthContextOutcome)('ws', 'invalid_token');
419
458
  logger_1.logger.warn('WebSocket auth rejected — closing connection', {
420
459
  reason,
421
460
  });