@adaptic/backend-legacy 0.0.1009 → 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.
|
|
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)()
|
|
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,
|
|
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
|
});
|