@constructive-io/graphql-server 5.20.8 → 5.22.0
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.
- package/README.md +3 -1
- package/diagnostics/debug-memory-snapshot.d.ts +3 -0
- package/diagnostics/debug-memory-snapshot.js +2 -0
- package/errors/graphql-response.d.ts +17 -3
- package/errors/graphql-response.js +7 -4
- package/esm/diagnostics/debug-memory-snapshot.js +2 -0
- package/esm/errors/graphql-response.js +7 -4
- package/esm/index.js +1 -1
- package/esm/middleware/admission-control.js +132 -0
- package/esm/middleware/api.js +8 -3
- package/esm/middleware/flush.js +37 -6
- package/esm/middleware/graphile.js +20 -3
- package/esm/middleware/request-protection.js +2 -0
- package/esm/middleware/routing.js +32 -3
- package/esm/plugins/request-protection-plugin.js +26 -1
- package/esm/refusals/recorder.js +136 -0
- package/esm/server.js +22 -2
- package/index.d.ts +1 -1
- package/index.js +2 -2
- package/middleware/admission-control.d.ts +20 -0
- package/middleware/admission-control.js +136 -0
- package/middleware/api.js +7 -2
- package/middleware/flush.d.ts +8 -2
- package/middleware/flush.js +39 -8
- package/middleware/graphile.js +20 -3
- package/middleware/request-protection.js +2 -0
- package/middleware/routing.d.ts +6 -0
- package/middleware/routing.js +34 -4
- package/middleware/types.d.ts +3 -0
- package/package.json +15 -15
- package/plugins/request-protection-plugin.d.ts +3 -0
- package/plugins/request-protection-plugin.js +28 -2
- package/refusals/recorder.d.ts +78 -0
- package/refusals/recorder.js +147 -0
- package/server.d.ts +1 -0
- package/server.js +21 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import '../middleware/types'; // for Request type
|
|
2
|
+
import { clientIpFrom, createRecordRefusalsSink, RefusalRecorder, trustedProxyHops } from '@constructive-io/express-context';
|
|
3
|
+
import { Logger } from '@pgpmjs/logger';
|
|
4
|
+
import { getPgPool } from 'pg-cache';
|
|
5
|
+
const log = new Logger('refusals');
|
|
6
|
+
/**
|
|
7
|
+
* refusals/recorder — the GraphQL lane's `RefusalRecorder`.
|
|
8
|
+
*
|
|
9
|
+
* One recorder per process, installed by the server at startup and read by
|
|
10
|
+
* every refusal site through `recordRefusal`. Emitters never see the recorder,
|
|
11
|
+
* the pool or a promise: `recordRefusal` is a synchronous counter bump that
|
|
12
|
+
* cannot fail the response being written around it. A harness that mounts a
|
|
13
|
+
* middleware without a server has no recorder installed and the call is a
|
|
14
|
+
* no-op.
|
|
15
|
+
*
|
|
16
|
+
* ## Flush identity
|
|
17
|
+
*
|
|
18
|
+
* A flush runs outside any tenant request, so it establishes its own identity
|
|
19
|
+
* at the top of its transaction: the `platform-bootstrap` service principal
|
|
20
|
+
* (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
|
|
21
|
+
* (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
|
|
22
|
+
* `system` role type (`jwt.claims.role_type`), which the generated writer's
|
|
23
|
+
* guard and RESTRICTIVE insert policy require. Those are the claims every
|
|
24
|
+
* other unattended platform write carries; resolving them is the
|
|
25
|
+
* claim-establishment step at the entry point, not a lookup inside the
|
|
26
|
+
* function being called — `record_refusals` raises if the claims are missing.
|
|
27
|
+
* Resolution is cached after the first success; a failure is reported by the
|
|
28
|
+
* recorder as a failed flush (loud, every interval) and retried next time.
|
|
29
|
+
*
|
|
30
|
+
* @module refusals/recorder
|
|
31
|
+
*/
|
|
32
|
+
/** The principal an unattended platform write acts as. */
|
|
33
|
+
export const PLATFORM_BOOTSTRAP_PRINCIPAL = 'platform-bootstrap';
|
|
34
|
+
let installed = null;
|
|
35
|
+
/** Make `recorder` the process's recorder. Returns the previous one, if any. */
|
|
36
|
+
export const installRefusalRecorder = (recorder) => {
|
|
37
|
+
const previous = installed;
|
|
38
|
+
installed = recorder;
|
|
39
|
+
return previous;
|
|
40
|
+
};
|
|
41
|
+
export const getRefusalRecorder = () => installed;
|
|
42
|
+
export const getRefusalRecorderStats = () => installed?.stats() ?? null;
|
|
43
|
+
/** The route half of a refusal key — the same shape admission control keys on. */
|
|
44
|
+
export const routeKeyOf = (req) => `${req.method} ${req.baseUrl ?? ''}${req.path ?? req.url ?? ''}`;
|
|
45
|
+
/**
|
|
46
|
+
* How far back through `X-Forwarded-For` to believe; mirrors admission
|
|
47
|
+
* control's resolution so the refusal source is the same address the limiter
|
|
48
|
+
* keyed on.
|
|
49
|
+
*/
|
|
50
|
+
const resolveHops = (req, configured) => {
|
|
51
|
+
if (typeof configured === 'number')
|
|
52
|
+
return configured;
|
|
53
|
+
const fromEnv = trustedProxyHops();
|
|
54
|
+
if (fromEnv > 0)
|
|
55
|
+
return fromEnv;
|
|
56
|
+
return req.app?.get('trust proxy') ? 1 : 0;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
|
|
60
|
+
* when no recorder is installed.
|
|
61
|
+
*/
|
|
62
|
+
export const recordRefusal = (req, reason, opts = {}) => {
|
|
63
|
+
const recorder = installed;
|
|
64
|
+
if (!recorder)
|
|
65
|
+
return;
|
|
66
|
+
const refusal = {
|
|
67
|
+
databaseId: opts.databaseId !== undefined ? opts.databaseId : req.databaseId ?? null,
|
|
68
|
+
lane: 'graphql',
|
|
69
|
+
reason,
|
|
70
|
+
routeKey: routeKeyOf(req),
|
|
71
|
+
sourceIp: opts.sourceIp !== undefined
|
|
72
|
+
? opts.sourceIp
|
|
73
|
+
: clientIpFrom(req, resolveHops(req, opts.trustedProxyHops))
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
recorder.record(refusal);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// The refusal response is already being written; a broken recorder is
|
|
80
|
+
// reported here and via the recorder's own stats, never to the client.
|
|
81
|
+
log.error(`refusal not recorded reason=${reason}: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the claims a flush runs under: the platform database and the
|
|
86
|
+
* `platform-bootstrap` service principal's user row. Throws — with the row
|
|
87
|
+
* that was missing named — rather than returning a partial identity.
|
|
88
|
+
*/
|
|
89
|
+
export const resolvePlatformFlushIdentity = async (pool, principalName = PLATFORM_BOOTSTRAP_PRINCIPAL) => {
|
|
90
|
+
const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
|
|
91
|
+
if (database.rowCount !== 1) {
|
|
92
|
+
throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
|
|
93
|
+
}
|
|
94
|
+
const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
|
|
95
|
+
if (principal.rowCount !== 1) {
|
|
96
|
+
throw new Error(`refusals: no service principal named '${principalName}'`);
|
|
97
|
+
}
|
|
98
|
+
if (principal.rows[0].bypass_step_up !== true) {
|
|
99
|
+
throw new Error(`refusals: principal '${principalName}' is not a service principal (no bypass_step_up)`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
databaseId: database.rows[0].id,
|
|
103
|
+
actorId: principal.rows[0].user_id,
|
|
104
|
+
principalId: principal.rows[0].user_id
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
export const platformFlushClaims = (identity) => ({
|
|
108
|
+
'jwt.claims.database_id': identity.databaseId,
|
|
109
|
+
'jwt.claims.user_id': identity.actorId,
|
|
110
|
+
'jwt.claims.principal_id': identity.principalId,
|
|
111
|
+
'jwt.claims.entity_id': identity.databaseId,
|
|
112
|
+
'jwt.claims.entity_type': 'database',
|
|
113
|
+
'jwt.claims.role_type': 'system'
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* The recorder the server runs: counts in memory, flushes into
|
|
117
|
+
* `constructive_usage_private.record_refusals` on the platform pool under the
|
|
118
|
+
* platform flush identity. Not started; the caller owns start/stop.
|
|
119
|
+
*/
|
|
120
|
+
export const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
|
|
121
|
+
const pool = getPgPool(opts.pg);
|
|
122
|
+
let cached = null;
|
|
123
|
+
const claims = async () => {
|
|
124
|
+
if (cached)
|
|
125
|
+
return cached;
|
|
126
|
+
cached = platformFlushClaims(await resolvePlatformFlushIdentity(pool, recorderOpts.principalName));
|
|
127
|
+
log.info(`[refusals] flushing as '${recorderOpts.principalName ?? PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
|
|
128
|
+
return cached;
|
|
129
|
+
};
|
|
130
|
+
return new RefusalRecorder({
|
|
131
|
+
sink: createRecordRefusalsSink({ pool, claims }),
|
|
132
|
+
intervalMs: recorderOpts.intervalMs,
|
|
133
|
+
jitterMs: recorderOpts.jitterMs,
|
|
134
|
+
maxKeys: recorderOpts.maxKeys
|
|
135
|
+
});
|
|
136
|
+
};
|
package/esm/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createAgenticRouter } from './agentic';
|
|
|
14
14
|
import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
|
|
15
15
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
16
16
|
import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability';
|
|
17
|
+
import { createAdmissionControlMiddleware } from './middleware/admission-control';
|
|
17
18
|
import { createApiMiddleware } from './middleware/api';
|
|
18
19
|
import { createAuthenticateMiddleware } from './middleware/auth';
|
|
19
20
|
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
@@ -22,7 +23,7 @@ import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
|
22
23
|
import { cors } from './middleware/cors';
|
|
23
24
|
import { errorHandler, notFoundHandler } from './middleware/error-handler';
|
|
24
25
|
import { favicon } from './middleware/favicon';
|
|
25
|
-
import {
|
|
26
|
+
import { createFlushMiddleware, flushService } from './middleware/flush';
|
|
26
27
|
import { createFnRouter } from './middleware/fn';
|
|
27
28
|
import { graphile } from './middleware/graphile';
|
|
28
29
|
import { multipartBridge } from './middleware/multipart-bridge';
|
|
@@ -32,6 +33,7 @@ import { localObservabilityOnly } from './middleware/observability/guard';
|
|
|
32
33
|
import { createRequestLogger } from './middleware/observability/request-logger';
|
|
33
34
|
import { createRequestProtectionMiddleware } from './middleware/request-protection';
|
|
34
35
|
import { getRoutingSchema } from './middleware/routing';
|
|
36
|
+
import { createPlatformRefusalRecorder, installRefusalRecorder } from './refusals/recorder';
|
|
35
37
|
const log = new Logger('server');
|
|
36
38
|
/**
|
|
37
39
|
* Creates and starts a GraphQL server instance
|
|
@@ -69,6 +71,7 @@ class Server {
|
|
|
69
71
|
closed = false;
|
|
70
72
|
httpServer = null;
|
|
71
73
|
debugSampler = null;
|
|
74
|
+
refusalRecorder = null;
|
|
72
75
|
constructor(opts) {
|
|
73
76
|
this.opts = getEnvOptions(opts);
|
|
74
77
|
const effectiveOpts = this.opts;
|
|
@@ -146,6 +149,13 @@ class Server {
|
|
|
146
149
|
// Resolve the tenant's protection bounds before anything can spend budget
|
|
147
150
|
// on the request (and before the GraphQL handler reads them for pgSettings).
|
|
148
151
|
app.use(createRequestProtectionMiddleware());
|
|
152
|
+
// Spend the width and rate bounds the line above resolved, before a
|
|
153
|
+
// request can take a pool connection. Scoped to /graphql rather than
|
|
154
|
+
// mounted globally because a concurrency slot is held for as long as the
|
|
155
|
+
// handler runs: the SSE routes below are long-lived by design and would
|
|
156
|
+
// sit in the budget for the life of the stream. The other lanes need
|
|
157
|
+
// their own bound sized for streaming, not this one.
|
|
158
|
+
app.use('/graphql', createAdmissionControlMiddleware());
|
|
149
159
|
app.use(createCaptchaMiddleware());
|
|
150
160
|
// CSRF protection for cookie-authenticated requests
|
|
151
161
|
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|
|
@@ -181,12 +191,17 @@ class Server {
|
|
|
181
191
|
// REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
|
|
182
192
|
app.use(createFnRouter());
|
|
183
193
|
app.use(graphile(effectiveOpts));
|
|
184
|
-
app.use(
|
|
194
|
+
app.use(createFlushMiddleware(effectiveOpts));
|
|
185
195
|
// Error handling - MUST be LAST
|
|
186
196
|
app.use(notFoundHandler); // Catches unmatched routes (404)
|
|
187
197
|
app.use(errorHandler); // Catches all thrown errors
|
|
188
198
|
this.app = app;
|
|
189
199
|
this.debugSampler = observabilityEnabled ? startDebugSampler(effectiveOpts) : null;
|
|
200
|
+
// Refusals are counted in memory by the middleware above and flushed to
|
|
201
|
+
// the platform table on a timer; the request path never touches the pool.
|
|
202
|
+
this.refusalRecorder = createPlatformRefusalRecorder(effectiveOpts);
|
|
203
|
+
installRefusalRecorder(this.refusalRecorder);
|
|
204
|
+
this.refusalRecorder.start();
|
|
190
205
|
}
|
|
191
206
|
listen() {
|
|
192
207
|
const { server } = this.opts;
|
|
@@ -291,6 +306,11 @@ class Server {
|
|
|
291
306
|
this.closed = true;
|
|
292
307
|
this.shuttingDown = true;
|
|
293
308
|
await this.removeEventListener();
|
|
309
|
+
if (this.refusalRecorder) {
|
|
310
|
+
installRefusalRecorder(null);
|
|
311
|
+
await this.refusalRecorder.stop();
|
|
312
|
+
this.refusalRecorder = null;
|
|
313
|
+
}
|
|
294
314
|
if (this.debugSampler) {
|
|
295
315
|
await this.debugSampler.stop();
|
|
296
316
|
this.debugSampler = null;
|
package/index.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ export * from './server';
|
|
|
2
2
|
export { createApiMiddleware, getApiConfig, getSubdomain } from './middleware/api';
|
|
3
3
|
export { createAuthenticateMiddleware } from './middleware/auth';
|
|
4
4
|
export { cors } from './middleware/cors';
|
|
5
|
-
export {
|
|
5
|
+
export { createFlushMiddleware, flushService } from './middleware/flush';
|
|
6
6
|
export { graphile } from './middleware/graphile';
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.graphile = exports.flushService = exports.
|
|
17
|
+
exports.graphile = exports.flushService = exports.createFlushMiddleware = exports.cors = exports.createAuthenticateMiddleware = exports.getSubdomain = exports.getApiConfig = exports.createApiMiddleware = void 0;
|
|
18
18
|
__exportStar(require("./server"), exports);
|
|
19
19
|
// Export middleware for use in testing packages
|
|
20
20
|
var api_1 = require("./middleware/api");
|
|
@@ -26,7 +26,7 @@ Object.defineProperty(exports, "createAuthenticateMiddleware", { enumerable: tru
|
|
|
26
26
|
var cors_1 = require("./middleware/cors");
|
|
27
27
|
Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
|
|
28
28
|
var flush_1 = require("./middleware/flush");
|
|
29
|
-
Object.defineProperty(exports, "
|
|
29
|
+
Object.defineProperty(exports, "createFlushMiddleware", { enumerable: true, get: function () { return flush_1.createFlushMiddleware; } });
|
|
30
30
|
Object.defineProperty(exports, "flushService", { enumerable: true, get: function () { return flush_1.flushService; } });
|
|
31
31
|
var graphile_1 = require("./middleware/graphile");
|
|
32
32
|
Object.defineProperty(exports, "graphile", { enumerable: true, get: function () { return graphile_1.graphile; } });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import './types';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
export interface AdmissionControlOptions {
|
|
4
|
+
/**
|
|
5
|
+
* How many proxies of our own sit in front of the server. Defaults to
|
|
6
|
+
* `TRUSTED_PROXY_HOPS`, then to 1 when Express is configured to trust a
|
|
7
|
+
* proxy at all; see `clientIpFrom` for why guessing higher is unsafe.
|
|
8
|
+
*/
|
|
9
|
+
trustedProxyHops?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
|
|
13
|
+
* them per database into the handler chain.
|
|
14
|
+
*
|
|
15
|
+
* Mount after `createRequestProtectionMiddleware` (which resolves the bounds
|
|
16
|
+
* this reads) and before the GraphQL handler. Order within the middleware
|
|
17
|
+
* matters: the rate check is O(1) and runs first, so a flood is refused
|
|
18
|
+
* without ever occupying a concurrency slot or waiting in its queue.
|
|
19
|
+
*/
|
|
20
|
+
export declare const createAdmissionControlMiddleware: (options?: AdmissionControlOptions) => RequestHandler;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAdmissionControlMiddleware = void 0;
|
|
4
|
+
require("./types"); // for Request type
|
|
5
|
+
const errors_1 = require("@constructive-io/errors");
|
|
6
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
7
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
8
|
+
const graphql_response_1 = require("../errors/graphql-response");
|
|
9
|
+
const recorder_1 = require("../refusals/recorder");
|
|
10
|
+
const log = new logger_1.Logger('admission');
|
|
11
|
+
/**
|
|
12
|
+
* admission-control — the gate that decides whether a GraphQL request starts.
|
|
13
|
+
*
|
|
14
|
+
* The document gate rejects a *shape* and the timeout GUCs bound a *duration*;
|
|
15
|
+
* neither refuses a request that is individually reasonable. This does, on two
|
|
16
|
+
* axes:
|
|
17
|
+
*
|
|
18
|
+
* 1. **Concurrency**, per database. The thing being protected is this
|
|
19
|
+
* process's PostgreSQL pool, so the counter is in-process and
|
|
20
|
+
* `maxConcurrentRequests` is a per-replica budget — a cluster-wide number
|
|
21
|
+
* would need shared state on the hot path to bound something that is not
|
|
22
|
+
* shared. Over budget, a request waits up to `maxQueueWaitMs` for a slot
|
|
23
|
+
* and is then refused; it never queues unbounded, because a queue that
|
|
24
|
+
* outgrows the timeout is just latency with a memory cost.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Rate**, per caller per route. Keyed on the client address rather than
|
|
27
|
+
* the tenant, because a tenant-wide limit is spent *by* an anonymous
|
|
28
|
+
* flood: exhausting it takes the tenant's own API down on the attacker's
|
|
29
|
+
* behalf. This is abuse protection and fails closed, which is the opposite
|
|
30
|
+
* of a billing quota (that serves and records overage) — the two must not
|
|
31
|
+
* be conflated, and neither is a database write on the request path.
|
|
32
|
+
*
|
|
33
|
+
* @module middleware/admission-control
|
|
34
|
+
*/
|
|
35
|
+
/** Window the per-caller rate is counted over — `rateLimitRpm` is per minute. */
|
|
36
|
+
const RATE_WINDOW_MS = 60_000;
|
|
37
|
+
/** What a request is keyed by when it carries no resolved database. */
|
|
38
|
+
const UNKNOWN_DATABASE = 'unknown';
|
|
39
|
+
const protectionOf = (req) => req.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
|
|
40
|
+
/**
|
|
41
|
+
* The route half of the rate key.
|
|
42
|
+
*
|
|
43
|
+
* Per-route rather than per-request-line so a caller cannot spread a flood
|
|
44
|
+
* across query strings, and so a cheap route's traffic does not spend the
|
|
45
|
+
* budget an expensive one needs.
|
|
46
|
+
*/
|
|
47
|
+
const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
|
|
48
|
+
/** Seconds for a `Retry-After` header — the smallest honest whole number. */
|
|
49
|
+
const retryAfterSeconds = (ms) => String(Math.max(Math.ceil(ms / 1000), 1));
|
|
50
|
+
/**
|
|
51
|
+
* How far back through `X-Forwarded-For` to believe.
|
|
52
|
+
*
|
|
53
|
+
* `req.clientIp` and `req.ip` are both unusable as a limiter key here: this
|
|
54
|
+
* server sets `trust proxy` to a predicate that returns true unconditionally,
|
|
55
|
+
* and `request-ip` reads the *leftmost* forwarded entry regardless — so either
|
|
56
|
+
* one hands a caller a fresh key per request for the cost of a header. The
|
|
57
|
+
* fallback to 1 hop exists because the opposite failure is just as bad: behind
|
|
58
|
+
* an ingress with no hop count configured, every caller resolves to the
|
|
59
|
+
* ingress's address and one abuser throttles the whole tenant.
|
|
60
|
+
*/
|
|
61
|
+
const resolveHops = (req, configured) => {
|
|
62
|
+
if (typeof configured === 'number')
|
|
63
|
+
return configured;
|
|
64
|
+
const fromEnv = (0, express_context_1.trustedProxyHops)();
|
|
65
|
+
if (fromEnv > 0)
|
|
66
|
+
return fromEnv;
|
|
67
|
+
return req.app?.get('trust proxy') ? 1 : 0;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
|
|
71
|
+
* them per database into the handler chain.
|
|
72
|
+
*
|
|
73
|
+
* Mount after `createRequestProtectionMiddleware` (which resolves the bounds
|
|
74
|
+
* this reads) and before the GraphQL handler. Order within the middleware
|
|
75
|
+
* matters: the rate check is O(1) and runs first, so a flood is refused
|
|
76
|
+
* without ever occupying a concurrency slot or waiting in its queue.
|
|
77
|
+
*/
|
|
78
|
+
const createAdmissionControlMiddleware = (options = {}) => {
|
|
79
|
+
const concurrency = new express_context_1.ConcurrencyLimiter();
|
|
80
|
+
const rate = new express_context_1.RateWindow(RATE_WINDOW_MS);
|
|
81
|
+
return async (req, res, next) => {
|
|
82
|
+
const protection = protectionOf(req);
|
|
83
|
+
const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
|
|
84
|
+
// ─── Per-caller rate ────────────────────────────────────────────────────
|
|
85
|
+
const hops = resolveHops(req, options.trustedProxyHops);
|
|
86
|
+
const ip = (0, express_context_1.clientIpFrom)(req, hops);
|
|
87
|
+
const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
|
|
88
|
+
if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
|
|
89
|
+
log.warn(`[admission] rate limit: database=${databaseId} ip=${ip} route=${routeOf(req)}`);
|
|
90
|
+
(0, recorder_1.recordRefusal)(req, 'rate_limited', { sourceIp: ip });
|
|
91
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.RATE_LIMITED(), {
|
|
92
|
+
status: 429,
|
|
93
|
+
headers: { 'Retry-After': retryAfterSeconds(rate.retryAfterMs(rateKey)) }
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// ─── Per-database concurrency ───────────────────────────────────────────
|
|
98
|
+
let lease;
|
|
99
|
+
try {
|
|
100
|
+
lease = await concurrency.acquire(databaseId, {
|
|
101
|
+
limit: protection.maxConcurrentRequests,
|
|
102
|
+
queueWaitMs: protection.maxQueueWaitMs
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
next(e);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!lease.granted) {
|
|
110
|
+
log.warn(`[admission] concurrency refused: database=${databaseId} ` +
|
|
111
|
+
`limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
|
|
112
|
+
(0, recorder_1.recordRefusal)(req, lease.refusal === 'queue_timeout' ? 'queue_timeout' : 'concurrency_saturated', {
|
|
113
|
+
sourceIp: ip
|
|
114
|
+
});
|
|
115
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.CONCURRENCY_LIMIT_REACHED({
|
|
116
|
+
limit: protection.maxConcurrentRequests,
|
|
117
|
+
waitedMs: lease.queuedMs
|
|
118
|
+
}), {
|
|
119
|
+
status: 429,
|
|
120
|
+
// The queue is the honest wait estimate: a slot freed sooner than
|
|
121
|
+
// this and the request would already have been admitted.
|
|
122
|
+
headers: { 'Retry-After': retryAfterSeconds(protection.maxQueueWaitMs) }
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Release exactly once, whichever ends first. `close` covers the client
|
|
127
|
+
// that hangs up mid-flight — without it an aborted request holds its slot
|
|
128
|
+
// until the process restarts, and a client that retries on abort would
|
|
129
|
+
// drain the tenant's budget one leaked slot at a time. `release` is
|
|
130
|
+
// idempotent, so both listeners can fire.
|
|
131
|
+
res.on('close', () => lease.release());
|
|
132
|
+
res.on('finish', () => lease.release());
|
|
133
|
+
next();
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
exports.createAdmissionControlMiddleware = createAdmissionControlMiddleware;
|
package/middleware/api.js
CHANGED
|
@@ -149,8 +149,8 @@ exports.getSvcKey = getSvcKey;
|
|
|
149
149
|
const toApiStructure = (row, opts, settings = {}) => ({
|
|
150
150
|
apiId: row.api_id,
|
|
151
151
|
dbname: row.dbname || opts.pg?.database || '',
|
|
152
|
-
anonRole: row.anon_role
|
|
153
|
-
roleName: row.role_name
|
|
152
|
+
anonRole: (0, routing_1.requireApiRole)('anon_role', row.anon_role, row.api_id),
|
|
153
|
+
roleName: (0, routing_1.requireApiRole)('role_name', row.role_name, row.api_id),
|
|
154
154
|
schema: row.schemas || [],
|
|
155
155
|
rlsModule: settings.rlsModule,
|
|
156
156
|
domains: [],
|
|
@@ -354,6 +354,11 @@ const createApiMiddleware = (opts) => {
|
|
|
354
354
|
res.status(404).send((0, _404_message_1.default)(err.message));
|
|
355
355
|
return;
|
|
356
356
|
}
|
|
357
|
+
if (err.code === 'MISSING_API_ROLE') {
|
|
358
|
+
log.error('[api-middleware] resolved API row has no served role:', err.message);
|
|
359
|
+
res.status(500).send(_50x_1.default);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
357
362
|
if (err.code === 'NO_DATABASE_ID') {
|
|
358
363
|
log.error('[api-middleware] no database id resolved:', err.message);
|
|
359
364
|
res.status(500).send(_50x_1.default);
|
package/middleware/flush.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import './types';
|
|
2
2
|
import { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { RequestHandler } from 'express';
|
|
4
|
+
/**
|
|
5
|
+
* `/flush` drops the routing and schema caches for the request's service key,
|
|
6
|
+
* so it is a control-plane operation: it needs the flush secret, not a tenant
|
|
7
|
+
* session. Without a configured secret there is no way to authenticate the
|
|
8
|
+
* caller, so the route stays closed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const createFlushMiddleware: (opts: ConstructiveOptions) => RequestHandler;
|
|
5
11
|
export declare const flushService: (opts: ConstructiveOptions, databaseId: string) => Promise<void>;
|
package/middleware/flush.js
CHANGED
|
@@ -1,24 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.flushService = exports.
|
|
3
|
+
exports.flushService = exports.createFlushMiddleware = void 0;
|
|
4
4
|
require("./types"); // for Request type
|
|
5
5
|
const logger_1 = require("@pgpmjs/logger");
|
|
6
6
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
7
8
|
const graphile_cache_1 = require("graphile-cache");
|
|
8
9
|
const pg_cache_1 = require("pg-cache");
|
|
9
10
|
const routing_1 = require("./routing");
|
|
10
11
|
const log = new logger_1.Logger('flush');
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const bearerToken = (req) => {
|
|
13
|
+
const header = req.get('authorization');
|
|
14
|
+
if (!header)
|
|
15
|
+
return null;
|
|
16
|
+
const [scheme, ...rest] = header.trim().split(/\s+/);
|
|
17
|
+
if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1)
|
|
18
|
+
return null;
|
|
19
|
+
return rest[0];
|
|
20
|
+
};
|
|
21
|
+
// Compare digests rather than the tokens themselves: timingSafeEqual requires
|
|
22
|
+
// equal lengths, and digests are equal-length whatever the caller presents.
|
|
23
|
+
const tokensMatch = (presented, expected) => (0, crypto_1.timingSafeEqual)((0, crypto_1.createHash)('sha256').update(presented).digest(), (0, crypto_1.createHash)('sha256').update(expected).digest());
|
|
24
|
+
/**
|
|
25
|
+
* `/flush` drops the routing and schema caches for the request's service key,
|
|
26
|
+
* so it is a control-plane operation: it needs the flush secret, not a tenant
|
|
27
|
+
* session. Without a configured secret there is no way to authenticate the
|
|
28
|
+
* caller, so the route stays closed.
|
|
29
|
+
*/
|
|
30
|
+
const createFlushMiddleware = (opts) => {
|
|
31
|
+
const expected = opts.api?.flushToken;
|
|
32
|
+
return async (req, res, next) => {
|
|
33
|
+
if (req.url !== '/flush') {
|
|
34
|
+
return next();
|
|
35
|
+
}
|
|
36
|
+
if (!expected) {
|
|
37
|
+
log.warn('[flush] rejected: no api.flushToken configured');
|
|
38
|
+
res.status(404).send('Not Found');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const presented = bearerToken(req);
|
|
42
|
+
if (!presented || !tokensMatch(presented, expected)) {
|
|
43
|
+
log.warn('[flush] rejected: invalid or missing bearer token');
|
|
44
|
+
res.status(401).send('Unauthorized');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
14
47
|
graphile_cache_1.graphileCache.delete(req.svc_key);
|
|
15
48
|
server_utils_1.svcCache.delete(req.svc_key);
|
|
16
49
|
res.status(200).send('OK');
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
return next();
|
|
50
|
+
};
|
|
20
51
|
};
|
|
21
|
-
exports.
|
|
52
|
+
exports.createFlushMiddleware = createFlushMiddleware;
|
|
22
53
|
const flushService = async (opts, databaseId) => {
|
|
23
54
|
const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
24
55
|
log.info('flushing db ' + databaseId);
|
package/middleware/graphile.js
CHANGED
|
@@ -61,7 +61,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
61
61
|
* plugin preset. Without settings the default preset is used
|
|
62
62
|
* (everything on except aggregates).
|
|
63
63
|
*/
|
|
64
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
64
|
+
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
65
65
|
return {
|
|
66
66
|
extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
|
|
67
67
|
plugins: [
|
|
@@ -90,7 +90,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
90
90
|
pgServices: [
|
|
91
91
|
(0, graphile_settings_1.makePgService)({
|
|
92
92
|
pool,
|
|
93
|
-
schemas
|
|
93
|
+
schemas,
|
|
94
|
+
// Introspection runs outside any request, so it has no served role to
|
|
95
|
+
// inherit: unset, it reads the catalog as whatever role the pool
|
|
96
|
+
// connected as (a superuser in most deployments) and the schema
|
|
97
|
+
// advertises that role's reach. Naming the role keeps schema shape
|
|
98
|
+
// tied to a bounded role's grants.
|
|
99
|
+
...(introspectionRole && {
|
|
100
|
+
pgSettingsForIntrospection: { role: introspectionRole }
|
|
101
|
+
})
|
|
94
102
|
})
|
|
95
103
|
],
|
|
96
104
|
grafserv: {
|
|
@@ -145,6 +153,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
145
153
|
if (req.token.session_id) {
|
|
146
154
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
147
155
|
}
|
|
156
|
+
if (req.token.root_session_id) {
|
|
157
|
+
pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
|
|
158
|
+
}
|
|
159
|
+
if (req.token.parent_session_id) {
|
|
160
|
+
pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
|
|
161
|
+
}
|
|
162
|
+
if (req.token.intent) {
|
|
163
|
+
pgSettings['jwt.claims.intent'] = req.token.intent;
|
|
164
|
+
}
|
|
148
165
|
// Propagate credential metadata as JWT claims so PG functions
|
|
149
166
|
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
150
167
|
if (req.token.access_level) {
|
|
@@ -293,7 +310,7 @@ const graphile = (opts) => {
|
|
|
293
310
|
const pool = (0, pg_cache_1.getPgPool)(pgConfig);
|
|
294
311
|
// Create promise and store in in-flight map BEFORE try block
|
|
295
312
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
296
|
-
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
313
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
|
|
297
314
|
const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
298
315
|
cacheKey: key,
|
|
299
316
|
serviceKey: key,
|
|
@@ -5,6 +5,7 @@ require("./types"); // for Request type
|
|
|
5
5
|
const errors_1 = require("@constructive-io/errors");
|
|
6
6
|
const express_context_1 = require("@constructive-io/express-context");
|
|
7
7
|
const graphql_response_1 = require("../errors/graphql-response");
|
|
8
|
+
const recorder_1 = require("../refusals/recorder");
|
|
8
9
|
/**
|
|
9
10
|
* Resolve the bounds this request runs under and attach them to it.
|
|
10
11
|
*
|
|
@@ -54,6 +55,7 @@ const createRequestProtectionMiddleware = () => {
|
|
|
54
55
|
if (Number.isFinite(declaredLength) &&
|
|
55
56
|
declaredLength > protection.maxRequestBytes &&
|
|
56
57
|
!isMultipart(req)) {
|
|
58
|
+
(0, recorder_1.recordRefusal)(req, 'request_too_large');
|
|
57
59
|
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
|
|
58
60
|
return;
|
|
59
61
|
}
|
package/middleware/routing.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
2
|
import { ApiOptions, ApiStructure } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* The role an API row names is the role the server will SET ROLE to; a row
|
|
5
|
+
* that leaves one blank is a broken surface, not a request for a default.
|
|
6
|
+
* Throws (code MISSING_API_ROLE) rather than substituting a role.
|
|
7
|
+
*/
|
|
8
|
+
export declare const requireApiRole: (column: "role_name" | "anon_role", value: string | null | undefined, apiId: string | undefined) => string;
|
|
3
9
|
/** Row shape returned by <schema>.resolve_route() — frozen DB↔server contract. */
|
|
4
10
|
export interface ResolvedRoute {
|
|
5
11
|
route_binding_id: string | null;
|
package/middleware/routing.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.routeToApiStructure = exports.resolveRoute = exports.isValidSchemaName = exports.getRoutingSchema = exports.DEFAULT_ROUTING_SCHEMA = void 0;
|
|
3
|
+
exports.routeToApiStructure = exports.resolveRoute = exports.isValidSchemaName = exports.getRoutingSchema = exports.DEFAULT_ROUTING_SCHEMA = exports.requireApiRole = void 0;
|
|
4
4
|
const logger_1 = require("@pgpmjs/logger");
|
|
5
5
|
const log = new logger_1.Logger('routing');
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// Scoped routing plane (resolve_route contract)
|
|
8
|
+
// =============================================================================
|
|
9
|
+
//
|
|
10
|
+
// One indexed call resolves an incoming request across scopes. The server's
|
|
11
|
+
// projection is HOST-ONLY: Traefik/Ingress owns L7 path/method routing, so
|
|
12
|
+
// the server always calls the frozen contract with the root path and no
|
|
13
|
+
// method:
|
|
14
|
+
//
|
|
15
|
+
// SELECT * FROM <schema>.resolve_route(request_host, '/', NULL)
|
|
16
|
+
//
|
|
17
|
+
// Contract (constructive-db docs/architecture/scoped-domain-routing.md):
|
|
18
|
+
// a single row is always returned; no match → route_binding_id IS NULL.
|
|
19
|
+
/**
|
|
20
|
+
* The role an API row names is the role the server will SET ROLE to; a row
|
|
21
|
+
* that leaves one blank is a broken surface, not a request for a default.
|
|
22
|
+
* Throws (code MISSING_API_ROLE) rather than substituting a role.
|
|
23
|
+
*/
|
|
24
|
+
const requireApiRole = (column, value, apiId) => {
|
|
25
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
26
|
+
const error = new Error(`API ${apiId ?? '<unknown>'} has no ${column}; a served role is required and there is no default.`);
|
|
27
|
+
error.code = 'MISSING_API_ROLE';
|
|
28
|
+
error.column = column;
|
|
29
|
+
error.apiId = apiId;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
exports.requireApiRole = requireApiRole;
|
|
6
35
|
const RESOLVER_FUNCTION = 'resolve_route';
|
|
7
36
|
/** Published logical name of the database-scope routing plane. */
|
|
8
37
|
exports.DEFAULT_ROUTING_SCHEMA = 'routing_public';
|
|
@@ -58,13 +87,14 @@ const routeToApiStructure = (route, opts) => {
|
|
|
58
87
|
log.debug('[resolve-route] api target missing schemas in resolved_config; no match');
|
|
59
88
|
return null;
|
|
60
89
|
}
|
|
90
|
+
const apiId = config.api_id ?? route.target_source_id ?? undefined;
|
|
61
91
|
return {
|
|
62
|
-
apiId
|
|
92
|
+
apiId,
|
|
63
93
|
// Scoped APIs leave dbname NULL when their schemas live in the serving
|
|
64
94
|
// database; fall back to the server's own database in that case.
|
|
65
95
|
dbname: config.dbname || opts.pg?.database || '',
|
|
66
|
-
anonRole: config.anon_role
|
|
67
|
-
roleName: config.role_name
|
|
96
|
+
anonRole: (0, exports.requireApiRole)('anon_role', config.anon_role, apiId),
|
|
97
|
+
roleName: (0, exports.requireApiRole)('role_name', config.role_name, apiId),
|
|
68
98
|
schema: config.schemas,
|
|
69
99
|
domains: [],
|
|
70
100
|
databaseId: config.database_id,
|
package/middleware/types.d.ts
CHANGED