@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
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ For the operational workflow, sampler output, and heap snapshot usage, see [docs
|
|
|
95
95
|
- `GET /graphiql` -> GraphiQL UI
|
|
96
96
|
- `GET /graphql` / `POST /graphql` -> GraphQL endpoint
|
|
97
97
|
- `POST /graphql` (multipart) -> file uploads
|
|
98
|
-
- `POST /flush` -> clears cached Graphile schema for the current API
|
|
98
|
+
- `POST /flush` -> clears cached Graphile schema for the current API; mounted only when `API_FLUSH_TOKEN` is set, and requires `Authorization: Bearer $API_FLUSH_TOKEN`. Without the variable the route is not served (404) — operators deploying a schema-cache flush must set it on the server and on every caller. The `LISTEN/NOTIFY` invalidation path is unaffected.
|
|
99
99
|
- `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
|
|
100
100
|
- `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
|
|
101
101
|
|
|
@@ -132,6 +132,8 @@ Configuration is merged from defaults, config files, and env vars via `@construc
|
|
|
132
132
|
| `API_META_SCHEMAS` | Meta schemas to query | `routing_public,metaschema_public,metaschema_modules_public` |
|
|
133
133
|
| `API_ANON_ROLE` | Anonymous role name | `administrator` |
|
|
134
134
|
| `API_ROLE_NAME` | Authenticated role name | `administrator` |
|
|
135
|
+
| `API_FLUSH_TOKEN` | Bearer token required by `POST /flush`; route is not mounted when unset | empty |
|
|
136
|
+
| `API_INTROSPECTION_ROLE` | Role PostGraphile introspects as; unset means the pool's connecting role. Naming a role with fewer grants removes fields from the schema | empty |
|
|
135
137
|
| `GRAPHQL_OBSERVABILITY_ENABLED` | Master switch for debug routes and sampler | `false` |
|
|
136
138
|
| `GRAPHQL_DEBUG_SAMPLER_ENABLED` | Enables periodic NDJSON sampling when observability is on | `true` |
|
|
137
139
|
| `GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS` | Sampler interval in milliseconds | `10000` |
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import v8 from 'node:v8';
|
|
2
2
|
import { getCacheStats } from 'graphile-cache';
|
|
3
3
|
import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
|
|
4
|
+
import { getRefusalRecorderStats } from '../refusals/recorder';
|
|
4
5
|
export interface DebugMemorySnapshot {
|
|
5
6
|
pid: number;
|
|
6
7
|
nodeEnv: string | undefined;
|
|
@@ -47,6 +48,8 @@ export interface DebugMemorySnapshot {
|
|
|
47
48
|
keys: string[];
|
|
48
49
|
};
|
|
49
50
|
graphileBuilds: ReturnType<typeof getGraphileBuildStats>;
|
|
51
|
+
/** In-memory refusal counter + flusher state; null when no recorder is installed. */
|
|
52
|
+
refusals: ReturnType<typeof getRefusalRecorderStats>;
|
|
50
53
|
uptimeMinutes: number;
|
|
51
54
|
timestamp: string;
|
|
52
55
|
}
|
|
@@ -10,6 +10,7 @@ const server_utils_1 = require("@pgpmjs/server-utils");
|
|
|
10
10
|
const graphile_cache_1 = require("graphile-cache");
|
|
11
11
|
const graphile_1 = require("../middleware/graphile");
|
|
12
12
|
const graphile_build_stats_1 = require("../middleware/observability/graphile-build-stats");
|
|
13
|
+
const recorder_1 = require("../refusals/recorder");
|
|
13
14
|
const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
14
15
|
const getDebugMemorySnapshot = () => {
|
|
15
16
|
const mem = process.memoryUsage();
|
|
@@ -70,6 +71,7 @@ const getDebugMemorySnapshot = () => {
|
|
|
70
71
|
keys: (0, graphile_1.getInFlightKeys)(),
|
|
71
72
|
},
|
|
72
73
|
graphileBuilds: (0, graphile_build_stats_1.getGraphileBuildStats)(),
|
|
74
|
+
refusals: (0, recorder_1.getRefusalRecorderStats)(),
|
|
73
75
|
uptimeMinutes: process.uptime() / 60,
|
|
74
76
|
timestamp: new Date().toISOString(),
|
|
75
77
|
};
|
|
@@ -12,10 +12,24 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { ConstructiveError } from '@constructive-io/errors';
|
|
14
14
|
import type { Response } from 'express';
|
|
15
|
+
/** Transport-level overrides for a short-circuited response. */
|
|
16
|
+
export interface GraphQLErrorResponseInit {
|
|
17
|
+
/**
|
|
18
|
+
* HTTP status to answer with, when 200 would hide the refusal from the
|
|
19
|
+
* machinery that has to act on it. Admission control is the case that needs
|
|
20
|
+
* it: the operation never reached the schema, and a client's backoff — and
|
|
21
|
+
* every proxy and load balancer between us — reads the status code, not a
|
|
22
|
+
* GraphQL error body.
|
|
23
|
+
*/
|
|
24
|
+
status?: number;
|
|
25
|
+
/** Extra headers, e.g. `Retry-After` on a 429. */
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
}
|
|
15
28
|
/**
|
|
16
29
|
* Send a {@link ConstructiveError} as a GraphQL error response.
|
|
17
30
|
*
|
|
18
|
-
*
|
|
19
|
-
* operation did not. The error's own `http` hint travels in
|
|
31
|
+
* Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
|
|
32
|
+
* succeeded, the operation did not. The error's own `http` hint travels in
|
|
33
|
+
* `extensions`.
|
|
20
34
|
*/
|
|
21
|
-
export declare function respondWithGraphQLError(res: Response, error: ConstructiveError): void;
|
|
35
|
+
export declare function respondWithGraphQLError(res: Response, error: ConstructiveError, init?: GraphQLErrorResponseInit): void;
|
|
@@ -16,11 +16,14 @@ exports.respondWithGraphQLError = respondWithGraphQLError;
|
|
|
16
16
|
/**
|
|
17
17
|
* Send a {@link ConstructiveError} as a GraphQL error response.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
* operation did not. The error's own `http` hint travels in
|
|
19
|
+
* Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
|
|
20
|
+
* succeeded, the operation did not. The error's own `http` hint travels in
|
|
21
|
+
* `extensions`.
|
|
21
22
|
*/
|
|
22
|
-
function respondWithGraphQLError(res, error) {
|
|
23
|
-
|
|
23
|
+
function respondWithGraphQLError(res, error, init = {}) {
|
|
24
|
+
if (init.headers)
|
|
25
|
+
res.set(init.headers);
|
|
26
|
+
res.status(init.status ?? 200).json({
|
|
24
27
|
errors: [{ message: error.message, extensions: error.toExtensions() }],
|
|
25
28
|
});
|
|
26
29
|
}
|
|
@@ -4,6 +4,7 @@ import { SVC_CACHE_TTL_MS, svcCache } from '@pgpmjs/server-utils';
|
|
|
4
4
|
import { getCacheStats } from 'graphile-cache';
|
|
5
5
|
import { getInFlightCount, getInFlightKeys } from '../middleware/graphile';
|
|
6
6
|
import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
|
|
7
|
+
import { getRefusalRecorderStats } from '../refusals/recorder';
|
|
7
8
|
const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
8
9
|
export const getDebugMemorySnapshot = () => {
|
|
9
10
|
const mem = process.memoryUsage();
|
|
@@ -64,6 +65,7 @@ export const getDebugMemorySnapshot = () => {
|
|
|
64
65
|
keys: getInFlightKeys(),
|
|
65
66
|
},
|
|
66
67
|
graphileBuilds: getGraphileBuildStats(),
|
|
68
|
+
refusals: getRefusalRecorderStats(),
|
|
67
69
|
uptimeMinutes: process.uptime() / 60,
|
|
68
70
|
timestamp: new Date().toISOString(),
|
|
69
71
|
};
|
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
/**
|
|
14
14
|
* Send a {@link ConstructiveError} as a GraphQL error response.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
* operation did not. The error's own `http` hint travels in
|
|
16
|
+
* Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
|
|
17
|
+
* succeeded, the operation did not. The error's own `http` hint travels in
|
|
18
|
+
* `extensions`.
|
|
18
19
|
*/
|
|
19
|
-
export function respondWithGraphQLError(res, error) {
|
|
20
|
-
|
|
20
|
+
export function respondWithGraphQLError(res, error, init = {}) {
|
|
21
|
+
if (init.headers)
|
|
22
|
+
res.set(init.headers);
|
|
23
|
+
res.status(init.status ?? 200).json({
|
|
21
24
|
errors: [{ message: error.message, extensions: error.toExtensions() }],
|
|
22
25
|
});
|
|
23
26
|
}
|
package/esm/index.js
CHANGED
|
@@ -3,5 +3,5 @@ export * from './server';
|
|
|
3
3
|
export { createApiMiddleware, getApiConfig, getSubdomain } from './middleware/api';
|
|
4
4
|
export { createAuthenticateMiddleware } from './middleware/auth';
|
|
5
5
|
export { cors } from './middleware/cors';
|
|
6
|
-
export {
|
|
6
|
+
export { createFlushMiddleware, flushService } from './middleware/flush';
|
|
7
7
|
export { graphile } from './middleware/graphile';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import './types'; // for Request type
|
|
2
|
+
import { errors } from '@constructive-io/errors';
|
|
3
|
+
import { clientIpFrom, ConcurrencyLimiter, DEFAULT_REQUEST_PROTECTION, RateWindow, trustedProxyHops } from '@constructive-io/express-context';
|
|
4
|
+
import { Logger } from '@pgpmjs/logger';
|
|
5
|
+
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
6
|
+
import { recordRefusal } from '../refusals/recorder';
|
|
7
|
+
const log = new Logger('admission');
|
|
8
|
+
/**
|
|
9
|
+
* admission-control — the gate that decides whether a GraphQL request starts.
|
|
10
|
+
*
|
|
11
|
+
* The document gate rejects a *shape* and the timeout GUCs bound a *duration*;
|
|
12
|
+
* neither refuses a request that is individually reasonable. This does, on two
|
|
13
|
+
* axes:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Concurrency**, per database. The thing being protected is this
|
|
16
|
+
* process's PostgreSQL pool, so the counter is in-process and
|
|
17
|
+
* `maxConcurrentRequests` is a per-replica budget — a cluster-wide number
|
|
18
|
+
* would need shared state on the hot path to bound something that is not
|
|
19
|
+
* shared. Over budget, a request waits up to `maxQueueWaitMs` for a slot
|
|
20
|
+
* and is then refused; it never queues unbounded, because a queue that
|
|
21
|
+
* outgrows the timeout is just latency with a memory cost.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Rate**, per caller per route. Keyed on the client address rather than
|
|
24
|
+
* the tenant, because a tenant-wide limit is spent *by* an anonymous
|
|
25
|
+
* flood: exhausting it takes the tenant's own API down on the attacker's
|
|
26
|
+
* behalf. This is abuse protection and fails closed, which is the opposite
|
|
27
|
+
* of a billing quota (that serves and records overage) — the two must not
|
|
28
|
+
* be conflated, and neither is a database write on the request path.
|
|
29
|
+
*
|
|
30
|
+
* @module middleware/admission-control
|
|
31
|
+
*/
|
|
32
|
+
/** Window the per-caller rate is counted over — `rateLimitRpm` is per minute. */
|
|
33
|
+
const RATE_WINDOW_MS = 60_000;
|
|
34
|
+
/** What a request is keyed by when it carries no resolved database. */
|
|
35
|
+
const UNKNOWN_DATABASE = 'unknown';
|
|
36
|
+
const protectionOf = (req) => req.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
|
|
37
|
+
/**
|
|
38
|
+
* The route half of the rate key.
|
|
39
|
+
*
|
|
40
|
+
* Per-route rather than per-request-line so a caller cannot spread a flood
|
|
41
|
+
* across query strings, and so a cheap route's traffic does not spend the
|
|
42
|
+
* budget an expensive one needs.
|
|
43
|
+
*/
|
|
44
|
+
const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
|
|
45
|
+
/** Seconds for a `Retry-After` header — the smallest honest whole number. */
|
|
46
|
+
const retryAfterSeconds = (ms) => String(Math.max(Math.ceil(ms / 1000), 1));
|
|
47
|
+
/**
|
|
48
|
+
* How far back through `X-Forwarded-For` to believe.
|
|
49
|
+
*
|
|
50
|
+
* `req.clientIp` and `req.ip` are both unusable as a limiter key here: this
|
|
51
|
+
* server sets `trust proxy` to a predicate that returns true unconditionally,
|
|
52
|
+
* and `request-ip` reads the *leftmost* forwarded entry regardless — so either
|
|
53
|
+
* one hands a caller a fresh key per request for the cost of a header. The
|
|
54
|
+
* fallback to 1 hop exists because the opposite failure is just as bad: behind
|
|
55
|
+
* an ingress with no hop count configured, every caller resolves to the
|
|
56
|
+
* ingress's address and one abuser throttles the whole tenant.
|
|
57
|
+
*/
|
|
58
|
+
const resolveHops = (req, configured) => {
|
|
59
|
+
if (typeof configured === 'number')
|
|
60
|
+
return configured;
|
|
61
|
+
const fromEnv = trustedProxyHops();
|
|
62
|
+
if (fromEnv > 0)
|
|
63
|
+
return fromEnv;
|
|
64
|
+
return req.app?.get('trust proxy') ? 1 : 0;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
|
|
68
|
+
* them per database into the handler chain.
|
|
69
|
+
*
|
|
70
|
+
* Mount after `createRequestProtectionMiddleware` (which resolves the bounds
|
|
71
|
+
* this reads) and before the GraphQL handler. Order within the middleware
|
|
72
|
+
* matters: the rate check is O(1) and runs first, so a flood is refused
|
|
73
|
+
* without ever occupying a concurrency slot or waiting in its queue.
|
|
74
|
+
*/
|
|
75
|
+
export const createAdmissionControlMiddleware = (options = {}) => {
|
|
76
|
+
const concurrency = new ConcurrencyLimiter();
|
|
77
|
+
const rate = new RateWindow(RATE_WINDOW_MS);
|
|
78
|
+
return async (req, res, next) => {
|
|
79
|
+
const protection = protectionOf(req);
|
|
80
|
+
const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
|
|
81
|
+
// ─── Per-caller rate ────────────────────────────────────────────────────
|
|
82
|
+
const hops = resolveHops(req, options.trustedProxyHops);
|
|
83
|
+
const ip = clientIpFrom(req, hops);
|
|
84
|
+
const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
|
|
85
|
+
if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
|
|
86
|
+
log.warn(`[admission] rate limit: database=${databaseId} ip=${ip} route=${routeOf(req)}`);
|
|
87
|
+
recordRefusal(req, 'rate_limited', { sourceIp: ip });
|
|
88
|
+
respondWithGraphQLError(res, errors.RATE_LIMITED(), {
|
|
89
|
+
status: 429,
|
|
90
|
+
headers: { 'Retry-After': retryAfterSeconds(rate.retryAfterMs(rateKey)) }
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// ─── Per-database concurrency ───────────────────────────────────────────
|
|
95
|
+
let lease;
|
|
96
|
+
try {
|
|
97
|
+
lease = await concurrency.acquire(databaseId, {
|
|
98
|
+
limit: protection.maxConcurrentRequests,
|
|
99
|
+
queueWaitMs: protection.maxQueueWaitMs
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
next(e);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!lease.granted) {
|
|
107
|
+
log.warn(`[admission] concurrency refused: database=${databaseId} ` +
|
|
108
|
+
`limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
|
|
109
|
+
recordRefusal(req, lease.refusal === 'queue_timeout' ? 'queue_timeout' : 'concurrency_saturated', {
|
|
110
|
+
sourceIp: ip
|
|
111
|
+
});
|
|
112
|
+
respondWithGraphQLError(res, errors.CONCURRENCY_LIMIT_REACHED({
|
|
113
|
+
limit: protection.maxConcurrentRequests,
|
|
114
|
+
waitedMs: lease.queuedMs
|
|
115
|
+
}), {
|
|
116
|
+
status: 429,
|
|
117
|
+
// The queue is the honest wait estimate: a slot freed sooner than
|
|
118
|
+
// this and the request would already have been admitted.
|
|
119
|
+
headers: { 'Retry-After': retryAfterSeconds(protection.maxQueueWaitMs) }
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Release exactly once, whichever ends first. `close` covers the client
|
|
124
|
+
// that hangs up mid-flight — without it an aborted request holds its slot
|
|
125
|
+
// until the process restarts, and a client that retries on abort would
|
|
126
|
+
// drain the tenant's budget one leaked slot at a time. `release` is
|
|
127
|
+
// idempotent, so both listeners can fire.
|
|
128
|
+
res.on('close', () => lease.release());
|
|
129
|
+
res.on('finish', () => lease.release());
|
|
130
|
+
next();
|
|
131
|
+
};
|
|
132
|
+
};
|
package/esm/middleware/api.js
CHANGED
|
@@ -6,7 +6,7 @@ import { svcCache } from '@pgpmjs/server-utils';
|
|
|
6
6
|
import { getPgPool } from 'pg-cache';
|
|
7
7
|
import errorPage50x from '../errors/50x';
|
|
8
8
|
import errorPage404Message from '../errors/404-message';
|
|
9
|
-
import { getRoutingSchema, isValidSchemaName, resolveRoute, routeToApiStructure } from './routing';
|
|
9
|
+
import { getRoutingSchema, isValidSchemaName, requireApiRole, resolveRoute, routeToApiStructure } from './routing';
|
|
10
10
|
const log = new Logger('api');
|
|
11
11
|
// =============================================================================
|
|
12
12
|
// Module Loader Registry (replaces inline SQL queries for per-db config)
|
|
@@ -141,8 +141,8 @@ export const getSvcKey = (opts, req) => {
|
|
|
141
141
|
const toApiStructure = (row, opts, settings = {}) => ({
|
|
142
142
|
apiId: row.api_id,
|
|
143
143
|
dbname: row.dbname || opts.pg?.database || '',
|
|
144
|
-
anonRole: row.anon_role
|
|
145
|
-
roleName: row.role_name
|
|
144
|
+
anonRole: requireApiRole('anon_role', row.anon_role, row.api_id),
|
|
145
|
+
roleName: requireApiRole('role_name', row.role_name, row.api_id),
|
|
146
146
|
schema: row.schemas || [],
|
|
147
147
|
rlsModule: settings.rlsModule,
|
|
148
148
|
domains: [],
|
|
@@ -345,6 +345,11 @@ export const createApiMiddleware = (opts) => {
|
|
|
345
345
|
res.status(404).send(errorPage404Message(err.message));
|
|
346
346
|
return;
|
|
347
347
|
}
|
|
348
|
+
if (err.code === 'MISSING_API_ROLE') {
|
|
349
|
+
log.error('[api-middleware] resolved API row has no served role:', err.message);
|
|
350
|
+
res.status(500).send(errorPage50x);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
348
353
|
if (err.code === 'NO_DATABASE_ID') {
|
|
349
354
|
log.error('[api-middleware] no database id resolved:', err.message);
|
|
350
355
|
res.status(500).send(errorPage50x);
|
package/esm/middleware/flush.js
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
1
|
import './types'; // for Request type
|
|
2
2
|
import { Logger } from '@pgpmjs/logger';
|
|
3
3
|
import { svcCache } from '@pgpmjs/server-utils';
|
|
4
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
4
5
|
import { graphileCache } from 'graphile-cache';
|
|
5
6
|
import { getPgPool } from 'pg-cache';
|
|
6
7
|
import { getRoutingSchema, isValidSchemaName } from './routing';
|
|
7
8
|
const log = new Logger('flush');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const bearerToken = (req) => {
|
|
10
|
+
const header = req.get('authorization');
|
|
11
|
+
if (!header)
|
|
12
|
+
return null;
|
|
13
|
+
const [scheme, ...rest] = header.trim().split(/\s+/);
|
|
14
|
+
if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1)
|
|
15
|
+
return null;
|
|
16
|
+
return rest[0];
|
|
17
|
+
};
|
|
18
|
+
// Compare digests rather than the tokens themselves: timingSafeEqual requires
|
|
19
|
+
// equal lengths, and digests are equal-length whatever the caller presents.
|
|
20
|
+
const tokensMatch = (presented, expected) => timingSafeEqual(createHash('sha256').update(presented).digest(), createHash('sha256').update(expected).digest());
|
|
21
|
+
/**
|
|
22
|
+
* `/flush` drops the routing and schema caches for the request's service key,
|
|
23
|
+
* so it is a control-plane operation: it needs the flush secret, not a tenant
|
|
24
|
+
* session. Without a configured secret there is no way to authenticate the
|
|
25
|
+
* caller, so the route stays closed.
|
|
26
|
+
*/
|
|
27
|
+
export const createFlushMiddleware = (opts) => {
|
|
28
|
+
const expected = opts.api?.flushToken;
|
|
29
|
+
return async (req, res, next) => {
|
|
30
|
+
if (req.url !== '/flush') {
|
|
31
|
+
return next();
|
|
32
|
+
}
|
|
33
|
+
if (!expected) {
|
|
34
|
+
log.warn('[flush] rejected: no api.flushToken configured');
|
|
35
|
+
res.status(404).send('Not Found');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const presented = bearerToken(req);
|
|
39
|
+
if (!presented || !tokensMatch(presented, expected)) {
|
|
40
|
+
log.warn('[flush] rejected: invalid or missing bearer token');
|
|
41
|
+
res.status(401).send('Unauthorized');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
11
44
|
graphileCache.delete(req.svc_key);
|
|
12
45
|
svcCache.delete(req.svc_key);
|
|
13
46
|
res.status(200).send('OK');
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
return next();
|
|
47
|
+
};
|
|
17
48
|
};
|
|
18
49
|
export const flushService = async (opts, databaseId) => {
|
|
19
50
|
const pgPool = getPgPool(opts.pg);
|
|
@@ -55,7 +55,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
55
55
|
* plugin preset. Without settings the default preset is used
|
|
56
56
|
* (everything on except aggregates).
|
|
57
57
|
*/
|
|
58
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
58
|
+
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
59
59
|
return {
|
|
60
60
|
extends: [createConstructivePreset(databaseSettings)],
|
|
61
61
|
plugins: [
|
|
@@ -84,7 +84,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
84
84
|
pgServices: [
|
|
85
85
|
makePgService({
|
|
86
86
|
pool,
|
|
87
|
-
schemas
|
|
87
|
+
schemas,
|
|
88
|
+
// Introspection runs outside any request, so it has no served role to
|
|
89
|
+
// inherit: unset, it reads the catalog as whatever role the pool
|
|
90
|
+
// connected as (a superuser in most deployments) and the schema
|
|
91
|
+
// advertises that role's reach. Naming the role keeps schema shape
|
|
92
|
+
// tied to a bounded role's grants.
|
|
93
|
+
...(introspectionRole && {
|
|
94
|
+
pgSettingsForIntrospection: { role: introspectionRole }
|
|
95
|
+
})
|
|
88
96
|
})
|
|
89
97
|
],
|
|
90
98
|
grafserv: {
|
|
@@ -139,6 +147,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
139
147
|
if (req.token.session_id) {
|
|
140
148
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
141
149
|
}
|
|
150
|
+
if (req.token.root_session_id) {
|
|
151
|
+
pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
|
|
152
|
+
}
|
|
153
|
+
if (req.token.parent_session_id) {
|
|
154
|
+
pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
|
|
155
|
+
}
|
|
156
|
+
if (req.token.intent) {
|
|
157
|
+
pgSettings['jwt.claims.intent'] = req.token.intent;
|
|
158
|
+
}
|
|
142
159
|
// Propagate credential metadata as JWT claims so PG functions
|
|
143
160
|
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
144
161
|
if (req.token.access_level) {
|
|
@@ -287,7 +304,7 @@ export const graphile = (opts) => {
|
|
|
287
304
|
const pool = getPgPool(pgConfig);
|
|
288
305
|
// Create promise and store in in-flight map BEFORE try block
|
|
289
306
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
290
|
-
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
307
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
|
|
291
308
|
const creationPromise = observeGraphileBuild({
|
|
292
309
|
cacheKey: key,
|
|
293
310
|
serviceKey: key,
|
|
@@ -2,6 +2,7 @@ import './types'; // for Request type
|
|
|
2
2
|
import { errors } from '@constructive-io/errors';
|
|
3
3
|
import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
|
|
4
4
|
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
5
|
+
import { recordRefusal } from '../refusals/recorder';
|
|
5
6
|
/**
|
|
6
7
|
* Resolve the bounds this request runs under and attach them to it.
|
|
7
8
|
*
|
|
@@ -51,6 +52,7 @@ export const createRequestProtectionMiddleware = () => {
|
|
|
51
52
|
if (Number.isFinite(declaredLength) &&
|
|
52
53
|
declaredLength > protection.maxRequestBytes &&
|
|
53
54
|
!isMultipart(req)) {
|
|
55
|
+
recordRefusal(req, 'request_too_large');
|
|
54
56
|
respondWithGraphQLError(res, errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
|
|
55
57
|
return;
|
|
56
58
|
}
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
import { Logger } from '@pgpmjs/logger';
|
|
2
2
|
const log = new Logger('routing');
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Scoped routing plane (resolve_route contract)
|
|
5
|
+
// =============================================================================
|
|
6
|
+
//
|
|
7
|
+
// One indexed call resolves an incoming request across scopes. The server's
|
|
8
|
+
// projection is HOST-ONLY: Traefik/Ingress owns L7 path/method routing, so
|
|
9
|
+
// the server always calls the frozen contract with the root path and no
|
|
10
|
+
// method:
|
|
11
|
+
//
|
|
12
|
+
// SELECT * FROM <schema>.resolve_route(request_host, '/', NULL)
|
|
13
|
+
//
|
|
14
|
+
// Contract (constructive-db docs/architecture/scoped-domain-routing.md):
|
|
15
|
+
// a single row is always returned; no match → route_binding_id IS NULL.
|
|
16
|
+
/**
|
|
17
|
+
* The role an API row names is the role the server will SET ROLE to; a row
|
|
18
|
+
* that leaves one blank is a broken surface, not a request for a default.
|
|
19
|
+
* Throws (code MISSING_API_ROLE) rather than substituting a role.
|
|
20
|
+
*/
|
|
21
|
+
export const requireApiRole = (column, value, apiId) => {
|
|
22
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
23
|
+
const error = new Error(`API ${apiId ?? '<unknown>'} has no ${column}; a served role is required and there is no default.`);
|
|
24
|
+
error.code = 'MISSING_API_ROLE';
|
|
25
|
+
error.column = column;
|
|
26
|
+
error.apiId = apiId;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
3
31
|
const RESOLVER_FUNCTION = 'resolve_route';
|
|
4
32
|
/** Published logical name of the database-scope routing plane. */
|
|
5
33
|
export const DEFAULT_ROUTING_SCHEMA = 'routing_public';
|
|
@@ -52,13 +80,14 @@ export const routeToApiStructure = (route, opts) => {
|
|
|
52
80
|
log.debug('[resolve-route] api target missing schemas in resolved_config; no match');
|
|
53
81
|
return null;
|
|
54
82
|
}
|
|
83
|
+
const apiId = config.api_id ?? route.target_source_id ?? undefined;
|
|
55
84
|
return {
|
|
56
|
-
apiId
|
|
85
|
+
apiId,
|
|
57
86
|
// Scoped APIs leave dbname NULL when their schemas live in the serving
|
|
58
87
|
// database; fall back to the server's own database in that case.
|
|
59
88
|
dbname: config.dbname || opts.pg?.database || '',
|
|
60
|
-
anonRole: config.anon_role
|
|
61
|
-
roleName: config.role_name
|
|
89
|
+
anonRole: requireApiRole('anon_role', config.anon_role, apiId),
|
|
90
|
+
roleName: requireApiRole('role_name', config.role_name, apiId),
|
|
62
91
|
schema: config.schemas,
|
|
63
92
|
domains: [],
|
|
64
93
|
databaseId: config.database_id,
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import '../middleware/types'; // for Request type
|
|
2
2
|
import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
|
|
3
|
+
import { SafeError } from 'grafast';
|
|
3
4
|
import { enforceDocumentProtection } from '../protection/document-gate';
|
|
5
|
+
import { recordRefusal } from '../refusals/recorder';
|
|
6
|
+
/** The document-gate error codes, as the refusal taxonomy names them. */
|
|
7
|
+
const DOCUMENT_REFUSALS = {
|
|
8
|
+
QUERY_TOO_DEEP: 'query_too_deep',
|
|
9
|
+
QUERY_TOO_COSTLY: 'query_too_costly',
|
|
10
|
+
PAGE_SIZE_TOO_LARGE: 'page_size_too_large'
|
|
11
|
+
};
|
|
12
|
+
/** The refusal a gate rejection counts as, or undefined for any other error. */
|
|
13
|
+
export const documentRefusalReason = (err) => {
|
|
14
|
+
if (!(err instanceof SafeError))
|
|
15
|
+
return undefined;
|
|
16
|
+
const code = err.extensions?.code;
|
|
17
|
+
return typeof code === 'string' ? DOCUMENT_REFUSALS[code] : undefined;
|
|
18
|
+
};
|
|
4
19
|
/**
|
|
5
20
|
* Get the Express request from a grafserv request context.
|
|
6
21
|
*/
|
|
@@ -31,7 +46,17 @@ export const RequestProtectionPlugin = {
|
|
|
31
46
|
// the platform defaults still apply rather than nothing at all.
|
|
32
47
|
const protection = req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
|
|
33
48
|
if (args.document && args.schema) {
|
|
34
|
-
|
|
49
|
+
try {
|
|
50
|
+
enforceDocumentProtection(args.schema, args.document, args.variableValues, protection, args.operationName);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
// The rejection is still the client's `SafeError`; it is counted on
|
|
54
|
+
// the way out, keyed by the tenant the request resolved.
|
|
55
|
+
const reason = documentRefusalReason(err);
|
|
56
|
+
if (reason && req)
|
|
57
|
+
recordRefusal(req, reason);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
35
60
|
}
|
|
36
61
|
return next();
|
|
37
62
|
}
|