From 84c55cccf87ac6c888bc2888d61e0595b22ab1ae Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Wed, 26 Aug 2026 22:39:35 +0700 Subject: [PATCH 01/10] feat(graphql): add database access policy gate --- graphql/env/README.md | 1 + graphql/env/__tests__/merge.test.ts | 10 + graphql/env/src/env.ts | 3 + graphql/server/README.md | 19 ++ .../__tests__/database-access-policy.test.ts | 288 ++++++++++++++++++ .../src/middleware/database-access-policy.ts | 194 ++++++++++++ graphql/server/src/server.ts | 3 + graphql/types/src/graphile.ts | 5 + 8 files changed, 523 insertions(+) create mode 100644 graphql/server/src/middleware/__tests__/database-access-policy.test.ts create mode 100644 graphql/server/src/middleware/database-access-policy.ts diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..5dff104656 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -51,6 +51,7 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### API Configuration - `API_ROUTING_SCHEMA` - Schema containing the compiled `resolve_route()` resolver (production routing always resolves through it) +- `API_DATABASE_ACCESS_POLICY_FUNCTION` - Optional schema-qualified function that authorizes requests for the resolved database - `API_IS_PUBLIC` - Whether API is public - `API_EXPOSED_SCHEMAS` - Comma-separated list of exposed schemas - `API_META_SCHEMAS` - Comma-separated list of meta schemas diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..84414c269e 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -138,6 +138,16 @@ describe('getEnvOptions', () => { expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + it('parses and trims the optional database access policy function', () => { + expect(getGraphQLEnvVars({ + API_DATABASE_ACCESS_POLICY_FUNCTION: ' platform_private.database_access ' + }).api?.databaseAccessPolicyFunction).toBe('platform_private.database_access'); + + expect(getGraphQLEnvVars({ + API_DATABASE_ACCESS_POLICY_FUNCTION: ' ' + }).api?.databaseAccessPolicyFunction).toBeUndefined(); + }); + it('parses SMS environment variables into typed options', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'devsms', diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..1407d419ef 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -13,6 +13,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial FEATURES_POSTGIS, API_ROUTING_SCHEMA, + API_DATABASE_ACCESS_POLICY_FUNCTION, API_IS_PUBLIC, API_EXPOSED_SCHEMAS, API_META_SCHEMAS, @@ -38,6 +39,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const databaseAccessPolicyFunction = API_DATABASE_ACCESS_POLICY_FUNCTION?.trim(); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -61,6 +63,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial }, api: { ...(API_ROUTING_SCHEMA && { routingSchema: API_ROUTING_SCHEMA }), + ...(databaseAccessPolicyFunction && { databaseAccessPolicyFunction }), ...(API_IS_PUBLIC && { isPublic: parseEnvBoolean(API_IS_PUBLIC) }), ...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }), ...(API_META_SCHEMAS && { metaSchemas: API_META_SCHEMAS.split(',').map(s => s.trim()) }), diff --git a/graphql/server/README.md b/graphql/server/README.md index f874d18e0f..bfd63e8c6b 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -111,6 +111,24 @@ This is a production-only server: every request is resolved through the scoped-r - `X-Meta-Schema` + `X-Database-Id` - A resolved database id is always required. There is no default database, so a request that resolves without a database id is rejected (`NO_DATABASE_ID` → HTTP 500). +### Database access policy + +Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route resolution and before tenant authentication, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. The option is disabled when unset; when configured, errors and malformed decisions fail closed and decisions are never cached. + +The function accepts one UUID database id and returns exactly one row: + +```sql +schema.function(p_database_id uuid) +returns table ( + allowed boolean, + code text, + message text, + http_status integer +) +``` + +An allowed row must set the three denial fields to `NULL`. A denied row must provide an uppercase machine code, a non-empty client-safe message of at most 512 characters, and an HTTP status from 400 through 599. GraphQL denials use HTTP 200 with that status in `errors[].extensions.http`; REST denials use the returned HTTP status. + ## Configuration Configuration is merged from defaults, config files, and env vars via `@constructive-io/graphql-env`. See `graphql/env/README.md` for the full list and examples. @@ -127,6 +145,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` | | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | | `API_ROUTING_SCHEMA` | Schema containing `resolve_route()` | `routing_public` | +| `API_DATABASE_ACCESS_POLICY_FUNCTION` | Schema-qualified resolved-database policy function | unset | | `API_IS_PUBLIC` | Serve public APIs only | `true` | | `API_EXPOSED_SCHEMAS` | Additional schemas to expose | empty | | `API_META_SCHEMAS` | Meta schemas to query | `routing_public,metaschema_public,metaschema_modules_public` | diff --git a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts new file mode 100644 index 0000000000..89b1ee0f84 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts @@ -0,0 +1,288 @@ +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +import { ConstructiveError } from '@constructive-io/errors'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import type { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import request from 'supertest'; + +import { ApiError } from '../../errors/api-errors'; +import type { ApiOptions } from '../../types'; +import { createDatabaseAccessPolicyMiddleware } from '../database-access-policy'; +import { errorHandler } from '../error-handler'; + +const mockGetPgPool = getPgPool as jest.MockedFunction; +const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; + +interface TestPolicyRow { + allowed: unknown; + code: unknown; + message: unknown; + http_status: unknown; +} + +const options = ( + databaseAccessPolicyFunction?: string, + isPublic = true +): ApiOptions => ({ + pg: { database: 'routing_database' }, + api: { + databaseAccessPolicyFunction, + isPublic + } +} as ApiOptions); + +const createRequest = ( + path = '/graphql', + headers: Record = {}, + databaseId: string | undefined = DATABASE_ID +): Request => ({ + path, + headers, + databaseId, + api: { dbname: 'customer_database' } +} as unknown as Request); + +const createResponse = (): { res: Response; status: jest.Mock; json: jest.Mock } => { + const status = jest.fn(); + const json = jest.fn(); + const res = { status, json } as unknown as Response; + status.mockReturnValue(res); + json.mockReturnValue(res); + return { res, status, json }; +}; + +const allowRow: TestPolicyRow = { + allowed: true, + code: null, + message: null, + http_status: null +}; + +const denyRow: TestPolicyRow = { + allowed: false, + code: 'DATABASE_BILLING_SUSPENDED', + message: 'This database is suspended until billing is restored.', + http_status: 402 +}; + +describe('database access policy middleware', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does nothing and creates no pool when the option is absent', async () => { + const middleware = createDatabaseAccessPolicyMiddleware(options()); + const next = jest.fn(); + + await middleware(createRequest(), createResponse().res, next); + + expect(mockGetPgPool).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it('queries the routing pool with the resolved database id and permits an allowed request', async () => { + const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const opts = options('platform_private.database_access'); + const middleware = createDatabaseAccessPolicyMiddleware(opts); + const next = jest.fn(); + + await middleware(createRequest(), createResponse().res, next); + + expect(mockGetPgPool).toHaveBeenCalledWith(opts.pg); + expect(query).toHaveBeenCalledWith( + expect.stringContaining('from "platform_private"."database_access"($1::uuid)'), + [DATABASE_ID] + ); + expect(next).toHaveBeenCalledWith(); + }); + + it.each([ + ['X-Api-Name', { 'x-api-name': 'customer' }], + ['X-Schemata', { 'x-schemata': 'app_public' }], + ['X-Meta-Schema', { 'x-meta-schema': 'tenant_meta' }] + ])('does not bypass a private %s request', async (_label, headers) => { + const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access', false) + ); + const next = jest.fn(); + + await middleware(createRequest('/graphql', headers), createResponse().res, next); + + expect(query).toHaveBeenCalledWith(expect.any(String), [DATABASE_ID]); + expect(next).toHaveBeenCalledWith(); + }); + + it('returns a GraphQL error envelope with the policy HTTP hint when access is denied', async () => { + const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, status, json } = createResponse(); + const next = jest.fn(); + + await middleware(createRequest('/graphql'), res, next); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ + errors: [{ + message: denyRow.message, + extensions: { + code: denyRow.code, + class: 'public', + http: 402 + } + }] + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('uses the REST error handler status and envelope outside GraphQL', async () => { + const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const app = express(); + app.use((req: Request, _res: Response, next: NextFunction) => { + req.databaseId = DATABASE_ID; + req.requestId = 'request-1'; + next(); + }); + app.use(middleware); + app.use((_req, res) => res.status(204).end()); + app.use(errorHandler); + + const response = await request(app) + .post('/fn/example') + .set('Accept', 'application/json'); + + expect(response.status).toBe(402); + expect(response.body).toEqual({ + error: { + code: denyRow.code, + message: denyRow.message, + requestId: 'request-1' + } + }); + }); + + it('fails closed without querying when the configured function name is unsafe', async () => { + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access;drop table users') + ); + const { res, status, json } = createResponse(); + const next = jest.fn(); + + await middleware(createRequest('/graphql'), res, next); + + expect(mockGetPgPool).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(200); + const error = json.mock.calls[0][0].errors[0]; + expect(error.message).toBe('Database access policy is temporarily unavailable.'); + expect(error.extensions).toEqual({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + class: 'internal', + http: 503 + }); + expect(next).not.toHaveBeenCalled(); + }); + + it.each([ + ['no rows', []], + ['multiple rows', [allowRow, allowRow]], + ['a non-boolean decision', [{ ...allowRow, allowed: 'true' }]], + ['denial fields on allow', [{ ...allowRow, code: 'UNEXPECTED' }]], + ['an unsafe denial code', [{ ...denyRow, code: 'bad-code' }]], + ['an empty denial message', [{ ...denyRow, message: ' ' }]], + ['an out-of-range status', [{ ...denyRow, http_status: 200 }]] + ])('fails closed when the policy returns %s', async (_label, rows) => { + const query = jest.fn().mockResolvedValue({ rows }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, json } = createResponse(); + const next = jest.fn(); + + await middleware(createRequest('/graphql'), res, next); + + expect(json.mock.calls[0][0].errors[0].extensions).toMatchObject({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + http: 503 + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('fails closed when policy evaluation throws', async () => { + const query = jest.fn().mockRejectedValue(new Error('connection lost')); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, json } = createResponse(); + const next = jest.fn(); + + await middleware(createRequest('/graphql'), res, next); + + expect(json.mock.calls[0][0].errors[0].extensions.code) + .toBe('DATABASE_ACCESS_POLICY_UNAVAILABLE'); + expect(next).not.toHaveBeenCalled(); + }); + + it('fails closed when API resolution did not supply a database id', async () => { + const query = jest.fn(); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, json } = createResponse(); + const next = jest.fn(); + const req = createRequest('/graphql'); + req.databaseId = undefined; + + await middleware(req, res, next); + + expect(query).not.toHaveBeenCalled(); + expect(json.mock.calls[0][0].errors[0].extensions.code) + .toBe('DATABASE_ACCESS_POLICY_UNAVAILABLE'); + expect(next).not.toHaveBeenCalled(); + }); + + it('evaluates the policy again for every request', async () => { + const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const next = jest.fn(); + + await middleware(createRequest(), createResponse().res, next); + await middleware(createRequest(), createResponse().res, next); + + expect(query).toHaveBeenCalledTimes(2); + expect(next).toHaveBeenCalledTimes(2); + }); + + it('passes a REST denial to the canonical typed error path', async () => { + const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const next = jest.fn(); + + await middleware(createRequest('/v1/agents'), createResponse().res, next); + + const [error] = next.mock.calls[0]; + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ code: denyRow.code, statusCode: 402 }); + expect(error).not.toBeInstanceOf(ConstructiveError); + }); +}); diff --git a/graphql/server/src/middleware/database-access-policy.ts b/graphql/server/src/middleware/database-access-policy.ts new file mode 100644 index 0000000000..20010804e0 --- /dev/null +++ b/graphql/server/src/middleware/database-access-policy.ts @@ -0,0 +1,194 @@ +import './types'; + +import { ConstructiveError } from '@constructive-io/errors'; +import { Logger } from '@pgpmjs/logger'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { getPgPool } from 'pg-cache'; + +import { ApiError } from '../errors/api-errors'; +import { respondWithGraphQLError } from '../errors/graphql-response'; +import type { ApiOptions } from '../types'; + +const log = new Logger('database-access-policy'); + +const POLICY_FUNCTION_PATTERN = /^([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)$/; +const POLICY_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/; +const MAX_POLICY_MESSAGE_LENGTH = 512; +const POLICY_UNAVAILABLE_CODE = 'DATABASE_ACCESS_POLICY_UNAVAILABLE'; +const POLICY_UNAVAILABLE_MESSAGE = 'Database access policy is temporarily unavailable.'; + +interface PolicyFunction { + schema: string; + name: string; +} + +interface PolicyDecisionRow { + allowed: unknown; + code: unknown; + message: unknown; + http_status: unknown; +} + +interface DeniedDecision { + allowed: false; + code: string; + message: string; + httpStatus: number; +} + +type PolicyDecision = { allowed: true } | DeniedDecision; + +const parsePolicyFunction = (value: string): PolicyFunction | null => { + const match = POLICY_FUNCTION_PATTERN.exec(value); + if (!match) return null; + return { schema: match[1], name: match[2] }; +}; + +const policyQuery = ({ schema, name }: PolicyFunction): string => + `select allowed, code, message, http_status +from "${schema}"."${name}"($1::uuid) +limit 2`; + +const parseDecision = (rows: PolicyDecisionRow[]): PolicyDecision => { + if (rows.length !== 1) { + throw new Error(`policy function returned ${rows.length} rows; expected exactly one`); + } + + const row = rows[0]; + if (row.allowed === true) { + if (row.code !== null || row.message !== null || row.http_status !== null) { + throw new Error('allowed policy decision must not include denial fields'); + } + return { allowed: true }; + } + + if (row.allowed !== false) { + throw new Error('policy decision allowed must be a boolean'); + } + + if (typeof row.code !== 'string' || !POLICY_ERROR_CODE_PATTERN.test(row.code)) { + throw new Error('denied policy decision has an invalid code'); + } + + if (typeof row.message !== 'string') { + throw new Error('denied policy decision has an invalid message'); + } + const message = row.message.trim(); + if (!message || message.length > MAX_POLICY_MESSAGE_LENGTH) { + throw new Error('denied policy decision has an invalid message'); + } + + if ( + !Number.isInteger(row.http_status) || + (row.http_status as number) < 400 || + (row.http_status as number) > 599 + ) { + throw new Error('denied policy decision has an invalid HTTP status'); + } + + return { + allowed: false, + code: row.code, + message, + httpStatus: row.http_status as number + }; +}; + +const isGraphQLRequest = (req: Request): boolean => + req.path === '/graphql' || req.path === '/graphql/'; + +const rejectRequest = ( + req: Request, + res: Response, + next: NextFunction, + decision: Omit, + errorClass: 'public' | 'internal' +): void => { + if (isGraphQLRequest(req)) { + respondWithGraphQLError( + res, + new ConstructiveError({ + code: decision.code, + message: decision.message, + errorClass, + http: decision.httpStatus + }) + ); + return; + } + + next(new ApiError(decision.code, decision.httpStatus, decision.message)); +}; + +const rejectUnavailable = ( + req: Request, + res: Response, + next: NextFunction +): void => rejectRequest( + req, + res, + next, + { + code: POLICY_UNAVAILABLE_CODE, + message: POLICY_UNAVAILABLE_MESSAGE, + httpStatus: 503 + }, + 'internal' +); + +/** + * Check a resolved database against an optional control-plane access policy. + * + * API resolution runs first and supplies `req.databaseId`. The policy query + * deliberately uses the server's configured routing/platform pool rather than + * the resolved tenant database, and it runs on every request so a cached API + * route cannot cache an access decision. + */ +export const createDatabaseAccessPolicyMiddleware = ( + opts: ApiOptions +): RequestHandler => { + const configuredFunction = opts.api?.databaseAccessPolicyFunction?.trim(); + if (!configuredFunction) { + return (_req, _res, next): void => next(); + } + + const fn = parsePolicyFunction(configuredFunction); + if (!fn) { + log.error( + '[database-access-policy] API_DATABASE_ACCESS_POLICY_FUNCTION must be two lowercase identifiers separated by a dot' + ); + return (req, res, next): void => rejectUnavailable(req, res, next); + } + + const pool = getPgPool(opts.pg); + const query = policyQuery(fn); + + return async (req, res, next): Promise => { + if (!req.databaseId) { + log.error('[database-access-policy] API resolution did not provide a database id'); + rejectUnavailable(req, res, next); + return; + } + + let decision: PolicyDecision; + try { + const result = await pool.query(query, [req.databaseId]); + decision = parseDecision(result.rows); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + log.error('[database-access-policy] policy evaluation failed', { + databaseId: req.databaseId, + error: detail + }); + rejectUnavailable(req, res, next); + return; + } + + if (decision.allowed === true) { + next(); + return; + } + + rejectRequest(req, res, next, decision, 'public'); + }; +}; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index ea7f1e94c8..77c758ea50 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -31,6 +31,7 @@ import { createAuthenticateMiddleware } from './middleware/auth'; import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { cors } from './middleware/cors'; +import { createDatabaseAccessPolicyMiddleware } from './middleware/database-access-policy'; import { errorHandler, notFoundHandler } from './middleware/error-handler'; import { favicon } from './middleware/favicon'; import { flush, flushService } from './middleware/flush'; @@ -91,6 +92,7 @@ class Server { const app = express(); const api = createApiMiddleware(effectiveOpts); + const databaseAccessPolicy = createDatabaseAccessPolicyMiddleware(effectiveOpts); const authenticate = createAuthenticateMiddleware(effectiveOpts); const requestLogger = createRequestLogger({ observabilityEnabled }); @@ -162,6 +164,7 @@ class Server { app.use(requestIdMiddleware()); app.use(requestLogger); app.use(api); + app.use(databaseAccessPolicy); app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 72fff4c739..a97ea1cc38 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -36,6 +36,11 @@ export interface ApiOptions { roleName?: string; /** Whether the API is publicly accessible */ isPublic?: boolean; + /** + * Optional schema-qualified PostgreSQL function that decides whether the + * resolved database may accept a new request. + */ + databaseAccessPolicyFunction?: string; /** Schemas containing metadata tables */ metaSchemas?: string[]; /** From d83f12faa1d655ac0d1f620af05c8652dfda3efd Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Wed, 26 Aug 2026 23:45:01 +0700 Subject: [PATCH 02/10] fix(graphql): gate databases before tenant hydration --- graphql/server/README.md | 6 +- graphql/server/src/errors/graphql-response.ts | 10 +- graphql/server/src/index.ts | 8 +- .../src/middleware/__tests__/api.test.ts | 6 +- .../database-access-policy-pipeline.test.ts | 257 ++++++++++++++++++ .../__tests__/database-access-policy.test.ts | 15 +- graphql/server/src/middleware/api.ts | 123 +++++++-- .../src/middleware/database-access-policy.ts | 3 +- graphql/server/src/server.ts | 4 +- 9 files changed, 383 insertions(+), 49 deletions(-) create mode 100644 graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts diff --git a/graphql/server/README.md b/graphql/server/README.md index bfd63e8c6b..e3ca685bd0 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -109,11 +109,11 @@ This is a production-only server: every request is resolved through the scoped-r - `X-Api-Name` + `X-Database-Id` - `X-Schemata` + `X-Database-Id` - `X-Meta-Schema` + `X-Database-Id` -- A resolved database id is always required. There is no default database, so a request that resolves without a database id is rejected (`NO_DATABASE_ID` → HTTP 500). +- A resolved database id is always required. There is no default database, so a request that resolves without one is rejected as `NO_DATABASE_ID` (HTTP 500) when no access policy is configured, or `DATABASE_ACCESS_POLICY_UNAVAILABLE` (HTTP 503) when the live policy must fail closed. ### Database access policy -Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route resolution and before tenant authentication, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. The option is disabled when unset; when configured, errors and malformed decisions fail closed and decisions are never cached. +Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route identity resolution and before tenant settings, tenant authentication, or request context, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. The option is disabled when unset; when configured, errors and malformed decisions fail closed and decisions are never cached. The function accepts one UUID database id and returns exactly one row: @@ -127,7 +127,7 @@ returns table ( ) ``` -An allowed row must set the three denial fields to `NULL`. A denied row must provide an uppercase machine code, a non-empty client-safe message of at most 512 characters, and an HTTP status from 400 through 599. GraphQL denials use HTTP 200 with that status in `errors[].extensions.http`; REST denials use the returned HTTP status. +An allowed row must set the three denial fields to `NULL`. A denied row must provide an uppercase machine code, a non-empty client-safe message of at most 512 characters, and an HTTP status from 400 through 599. GraphQL denials keep a GraphQL error envelope and use the returned HTTP status, which is also present in `errors[].extensions.http`; REST denials use the same status. ## Configuration diff --git a/graphql/server/src/errors/graphql-response.ts b/graphql/server/src/errors/graphql-response.ts index 58d72ecd36..3f2af52348 100644 --- a/graphql/server/src/errors/graphql-response.ts +++ b/graphql/server/src/errors/graphql-response.ts @@ -17,14 +17,16 @@ import type { Response } from 'express'; /** * Send a {@link ConstructiveError} as a GraphQL error response. * - * Uses HTTP 200 per the GraphQL-over-HTTP convention: transport succeeded, the - * operation did not. The error's own `http` hint travels in `extensions`. + * Uses HTTP 200 by default per the GraphQL-over-HTTP convention. Boundary + * policies with an explicit transport contract may supply a different status; + * the error's own `http` hint still travels in `extensions`. */ export function respondWithGraphQLError( res: Response, - error: ConstructiveError + error: ConstructiveError, + status = 200 ): void { - res.status(200).json({ + res.status(status).json({ errors: [{ message: error.message, extensions: error.toExtensions() }], }); } diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index edd35483ad..ec5c0e2daf 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -1,7 +1,13 @@ export * from './server'; // Export middleware for use in testing packages -export { createApiMiddleware, getApiConfig,getSubdomain } from './middleware/api'; +export { + createApiMiddleware, + createApiSettingsMiddleware, + getApiConfig, + getApiIdentity, + getSubdomain +} from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; export { cors } from './middleware/cors'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/__tests__/api.test.ts b/graphql/server/src/middleware/__tests__/api.test.ts index 965d01094c..dfc0020c34 100644 --- a/graphql/server/src/middleware/__tests__/api.test.ts +++ b/graphql/server/src/middleware/__tests__/api.test.ts @@ -14,7 +14,7 @@ import type { Pool } from 'pg'; import { getPgPool } from 'pg-cache'; import type { ApiOptions } from '../../types'; -import { getApiConfig, getSvcKey } from '../api'; +import { getApiIdentity, getSvcKey } from '../api'; const mockGetPgPool = getPgPool as jest.MockedFunction; @@ -61,7 +61,7 @@ describe('api middleware routing priority', () => { expect(getSvcKey(createPrivateOptions(), req)).toBe('api:db-123:customer-api'); }); - it('uses the same X-Api-Name priority when resolving and caching API config', async () => { + it('uses the same X-Api-Name priority when resolving and caching API identity', async () => { const query = jest.fn(async (_sql: string, params: unknown[]) => { if (Array.isArray(params[0])) { return { @@ -97,7 +97,7 @@ describe('api middleware routing priority', () => { 'X-Schemata': 'app_public' }); - const result = await getApiConfig(createPrivateOptions(), req); + const result = await getApiIdentity(createPrivateOptions(), req); expect(req.svc_key).toBe('api:db-123:customer-api'); expect(result).toMatchObject({ diff --git a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts new file mode 100644 index 0000000000..0f8ce6e10e --- /dev/null +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -0,0 +1,257 @@ +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +jest.mock('@constructive-io/express-context', () => ({ + createDefaultRegistry: jest.fn(() => ({ + resolve: jest.fn(async ( + _name: string, + ctx: { tenantPool: { query: (sql: string) => Promise } } + ): Promise => { + await ctx.tenantPool.query('select tenant_setting'); + return undefined; + }) + })) +})); + +import { createDefaultRegistry } from '@constructive-io/express-context'; +import { svcCache } from '@pgpmjs/server-utils'; +import express from 'express'; +import type { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import request from 'supertest'; + +import type { ApiOptions } from '../../types'; +import { createApiMiddleware, createApiSettingsMiddleware } from '../api'; +import { createDatabaseAccessPolicyMiddleware } from '../database-access-policy'; +import { errorHandler } from '../error-handler'; + +const mockGetPgPool = getPgPool as jest.MockedFunction; +const mockCreateDefaultRegistry = createDefaultRegistry as jest.MockedFunction; +const mockRegistryResolve = ( + mockCreateDefaultRegistry.mock.results[0]?.value as { resolve: jest.Mock } +).resolve; + +const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; +const TENANT_DATABASE = 'customer_database'; + +interface PolicyRow { + allowed: boolean; + code: string | null; + message: string | null; + http_status: number | null; +} + +const allowRow: PolicyRow = { + allowed: true, + code: null, + message: null, + http_status: null +}; + +const denyRow: PolicyRow = { + allowed: false, + code: 'DATABASE_BILLING_SUSPENDED', + message: 'This database is suspended until billing is restored.', + http_status: 402 +}; + +const options = (): ApiOptions => ({ + pg: { database: 'routing_database' }, + api: { + isPublic: false, + metaSchemas: ['metaschema_public'], + databaseAccessPolicyFunction: 'platform_private.database_access' + } +} as ApiOptions); + +const matchedRoute = () => ({ + route_binding_id: 'route-1', + target_module: 'api', + target_source_id: 'api-1', + resolved_config: { + api_id: 'api-1', + database_id: DATABASE_ID, + dbname: TENANT_DATABASE, + role_name: 'authenticated', + anon_role: 'anonymous', + is_public: false, + schemas: ['app_public'] + } +}); + +function setupPools(policyRows: PolicyRow[]) { + const events: string[] = []; + const tenantQuery = jest.fn(async () => { + events.push('tenant'); + return { rows: [] as unknown[] }; + }); + const policyQueue = [...policyRows]; + const routingQuery = jest.fn(async (sql: string, params: unknown[]) => { + if (sql.includes('information_schema.schemata')) { + events.push('schema-resolution'); + return { + rows: (params[0] as string[]).map((schema_name) => ({ schema_name })) + }; + } + if (sql.includes('FROM "routing_public".apis')) { + events.push('api-resolution'); + return { + rows: [{ + api_id: 'api-1', + database_id: DATABASE_ID, + dbname: TENANT_DATABASE, + role_name: 'authenticated', + anon_role: 'anonymous', + is_public: false, + schemas: ['app_public'] + }] + }; + } + if (sql.includes('resolve_route')) { + events.push('route-resolution'); + return { rows: [matchedRoute()] }; + } + if (sql.includes('platform_private"."database_access')) { + events.push('policy'); + return { rows: [policyQueue.shift() ?? policyRows[policyRows.length - 1]] }; + } + throw new Error(`Unexpected routing query: ${sql}`); + }); + + const routingPool = { query: routingQuery } as unknown as Pool; + const tenantPool = { query: tenantQuery } as unknown as Pool; + mockGetPgPool.mockImplementation((pgOptions) => ( + pgOptions?.database === TENANT_DATABASE ? tenantPool : routingPool + )); + + return { events, routingQuery, tenantQuery }; +} + +function pipelineApp(apiOptions = options()) { + const app = express(); + app.use(createApiMiddleware(apiOptions)); + app.use(createDatabaseAccessPolicyMiddleware(apiOptions)); + app.use(createApiSettingsMiddleware(apiOptions)); + app.use((_req, res) => res.status(204).end()); + app.use(errorHandler); + return app; +} + +describe('database access policy pipeline ordering', () => { + beforeEach(() => { + svcCache.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + svcCache.clear(); + }); + + it.each([ + ['scoped route', {}], + ['X-Api-Name', { 'X-Database-Id': DATABASE_ID, 'X-Api-Name': 'customer' }], + ['X-Schemata', { 'X-Database-Id': DATABASE_ID, 'X-Schemata': 'app_public' }], + ['X-Meta-Schema', { 'X-Database-Id': DATABASE_ID, 'X-Meta-Schema': 'true' }] + ])('denies %s before any tenant setting query', async (_label, headers) => { + const { tenantQuery } = setupPools([denyRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'api.example.com') + .set(headers) + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(402); + expect(response.body.errors[0].extensions).toMatchObject({ + code: 'DATABASE_BILLING_SUSPENDED', + http: 402 + }); + expect(tenantQuery).not.toHaveBeenCalled(); + expect(mockRegistryResolve).not.toHaveBeenCalled(); + }); + + it('hydrates tenant settings only after an allowed policy decision', async () => { + const { events, tenantQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'api.example.com') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(204); + expect(tenantQuery).toHaveBeenCalled(); + expect(events.indexOf('policy')).toBeGreaterThan(events.indexOf('route-resolution')); + expect(events.indexOf('tenant')).toBeGreaterThan(events.indexOf('policy')); + }); + + it.each([ + ['scoped route', {}, 'resolve_route'], + [ + 'X-Api-Name', + { 'X-Database-Id': DATABASE_ID, 'X-Api-Name': 'customer' }, + 'FROM "routing_public".apis' + ], + [ + 'X-Schemata', + { 'X-Database-Id': DATABASE_ID, 'X-Schemata': 'app_public' }, + 'information_schema.schemata' + ], + [ + 'X-Meta-Schema', + { 'X-Database-Id': DATABASE_ID, 'X-Meta-Schema': 'true' }, + 'information_schema.schemata' + ] + ])('re-evaluates a warm cached %s identity and never hydrates after denial', async ( + _label, + headers, + identitySql + ) => { + const { routingQuery, tenantQuery } = setupPools([allowRow, denyRow]); + const app = pipelineApp(); + + const first = await request(app) + .post('/graphql') + .set('Host', 'api.example.com') + .set(headers) + .send({ query: '{ __typename }' }); + const tenantQueriesAfterAllow = tenantQuery.mock.calls.length; + const settingsAfterAllow = mockRegistryResolve.mock.calls.length; + + const second = await request(app) + .post('/graphql') + .set('Host', 'api.example.com') + .set(headers) + .send({ query: '{ __typename }' }); + + expect(first.status).toBe(204); + expect(second.status).toBe(402); + expect(second.body.errors[0].extensions).toMatchObject({ + code: 'DATABASE_BILLING_SUSPENDED', + http: 402 + }); + expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes(identitySql))).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes('database_access'))).toHaveLength(2); + expect(tenantQuery).toHaveBeenCalledTimes(tenantQueriesAfterAllow); + expect(mockRegistryResolve).toHaveBeenCalledTimes(settingsAfterAllow); + }); + + it('fails closed with the policy-unavailable contract when private routing omits database identity', async () => { + const { routingQuery, tenantQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Schemata', 'app_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(503); + expect(response.body.errors[0].extensions).toMatchObject({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + http: 503 + }); + expect(routingQuery.mock.calls.some(([sql]) => String(sql).includes('database_access'))).toBe(false); + expect(tenantQuery).not.toHaveBeenCalled(); + expect(mockRegistryResolve).not.toHaveBeenCalled(); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts index 89b1ee0f84..e1e794b3d8 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts @@ -118,7 +118,7 @@ describe('database access policy middleware', () => { expect(next).toHaveBeenCalledWith(); }); - it('returns a GraphQL error envelope with the policy HTTP hint when access is denied', async () => { + it('returns the exact GraphQL 402 contract when access is denied', async () => { const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); mockGetPgPool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( @@ -129,7 +129,7 @@ describe('database access policy middleware', () => { await middleware(createRequest('/graphql'), res, next); - expect(status).toHaveBeenCalledWith(200); + expect(status).toHaveBeenCalledWith(402); expect(json).toHaveBeenCalledWith({ errors: [{ message: denyRow.message, @@ -183,7 +183,7 @@ describe('database access policy middleware', () => { await middleware(createRequest('/graphql'), res, next); expect(mockGetPgPool).not.toHaveBeenCalled(); - expect(status).toHaveBeenCalledWith(200); + expect(status).toHaveBeenCalledWith(503); const error = json.mock.calls[0][0].errors[0]; expect(error.message).toBe('Database access policy is temporarily unavailable.'); expect(error.extensions).toEqual({ @@ -208,7 +208,7 @@ describe('database access policy middleware', () => { const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); - const { res, json } = createResponse(); + const { res, status, json } = createResponse(); const next = jest.fn(); await middleware(createRequest('/graphql'), res, next); @@ -217,6 +217,7 @@ describe('database access policy middleware', () => { code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', http: 503 }); + expect(status).toHaveBeenCalledWith(503); expect(next).not.toHaveBeenCalled(); }); @@ -226,13 +227,14 @@ describe('database access policy middleware', () => { const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); - const { res, json } = createResponse(); + const { res, status, json } = createResponse(); const next = jest.fn(); await middleware(createRequest('/graphql'), res, next); expect(json.mock.calls[0][0].errors[0].extensions.code) .toBe('DATABASE_ACCESS_POLICY_UNAVAILABLE'); + expect(status).toHaveBeenCalledWith(503); expect(next).not.toHaveBeenCalled(); }); @@ -242,7 +244,7 @@ describe('database access policy middleware', () => { const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); - const { res, json } = createResponse(); + const { res, status, json } = createResponse(); const next = jest.fn(); const req = createRequest('/graphql'); req.databaseId = undefined; @@ -252,6 +254,7 @@ describe('database access policy middleware', () => { expect(query).not.toHaveBeenCalled(); expect(json.mock.calls[0][0].errors[0].extensions.code) .toBe('DATABASE_ACCESS_POLICY_UNAVAILABLE'); + expect(status).toHaveBeenCalledWith(503); expect(next).not.toHaveBeenCalled(); }); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 24f28e9228..59349b0d71 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -160,6 +160,43 @@ const resolveModuleSettings = async ( }; }; +/** + * Hydrate a resolved API identity with tenant-owned module settings. + * + * This must run only after any configured database access policy has allowed + * the request. Building the loader context creates the tenant pool used by + * settings loaders, so doing this during route resolution would let a denied + * request reach the tenant database. + */ +const hydrateApiStructure = async ( + pool: Pool, + opts: ApiOptions, + structure: ApiStructure +): Promise => { + if (!structure.databaseId || !structure.apiId) return structure; + + const loaderCtx = buildLoaderContext(pool, opts, { + api_id: structure.apiId, + database_id: structure.databaseId, + dbname: structure.dbname, + role_name: structure.roleName, + anon_role: structure.anonRole, + is_public: structure.isPublic ?? false, + schemas: structure.schema + }); + const settings = await resolveModuleSettings(defaultRegistry, loaderCtx); + + return { + ...structure, + rlsModule: settings.rlsModule, + authSettings: settings.authSettings, + corsOrigins: settings.corsOrigins, + databaseSettings: settings.databaseSettings, + pubkeyChallengeSettings: settings.pubkeyChallengeSettings, + webauthnSettings: settings.webauthnSettings + }; +}; + // ============================================================================= // Helpers // ============================================================================= @@ -331,10 +368,8 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise => { @@ -460,15 +475,34 @@ export const getApiConfig = async ( break; } - // Cache successful results + // Cache only route identity. Tenant module settings are resolved after the + // live access policy, so a warm service cache cannot bypass that policy. if (result && !isApiError(result)) { - assertDatabaseId(result); - svcCache.set(cacheKey, result); + if (result.databaseId) svcCache.set(cacheKey, result); } return result; }; +/** + * Resolve API identity for callers that use this helper directly. + * + * The server middleware uses `getApiIdentity` so a configured live policy can + * turn a missing identity into its stable fail-closed response. Direct callers + * keep the historical no-default-database assertion. + */ +export const getApiConfig = async ( + opts: ApiOptions, + req: Request +): Promise => { + const result = await getApiIdentity(opts, req); + if (result && !isApiError(result)) { + assertDatabaseId(result); + return hydrateApiStructure(getPgPool(opts.pg), opts, result); + } + return result; +}; + // ============================================================================= // Express Middleware // ============================================================================= @@ -478,7 +512,7 @@ export const createApiMiddleware = (opts: ApiOptions) => { log.debug(`[api-middleware] ${req.method} ${req.path}`); try { - const apiConfig = await getApiConfig(opts, req); + const apiConfig = await getApiIdentity(opts, req); if (isApiError(apiConfig)) { res.status(404).send(errorPage404Message('API not found', apiConfig.errorHtml)); @@ -492,6 +526,9 @@ export const createApiMiddleware = (opts: ApiOptions) => { req.api = apiConfig; req.databaseId = apiConfig.databaseId; + if (!req.databaseId && !opts.api?.databaseAccessPolicyFunction?.trim()) { + assertDatabaseId(apiConfig); + } log.debug(`Resolved API: db=${apiConfig.dbname}, schemas=[${apiConfig.schema?.join(', ')}]`); next(); } catch (error: unknown) { @@ -518,3 +555,29 @@ export const createApiMiddleware = (opts: ApiOptions) => { } }; }; + +/** + * Resolve tenant-owned API settings after the request's database identity has + * passed the optional live access policy. + */ +export const createApiSettingsMiddleware = (opts: ApiOptions) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.api || !req.databaseId) { + log.error('[api-settings-middleware] API identity was not resolved before settings hydration'); + res.status(500).send(errorPage50x); + return; + } + + try { + const pool = getPgPool(opts.pg); + req.api = await hydrateApiStructure(pool, opts, req.api); + log.debug( + `Hydrated API settings: db=${req.api.dbname}, rlsModule=${req.api.rlsModule ? 'found' : 'none'}, authSettings=${req.api.authSettings ? 'found' : 'none'}` + ); + next(); + } catch (error: unknown) { + log.error('[api-settings-middleware] API settings hydration failed:', error); + res.status(500).send(errorPage50x); + } + }; +}; diff --git a/graphql/server/src/middleware/database-access-policy.ts b/graphql/server/src/middleware/database-access-policy.ts index 20010804e0..75fa7e477d 100644 --- a/graphql/server/src/middleware/database-access-policy.ts +++ b/graphql/server/src/middleware/database-access-policy.ts @@ -112,7 +112,8 @@ const rejectRequest = ( message: decision.message, errorClass, http: decision.httpStatus - }) + }), + decision.httpStatus ); return; } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 77c758ea50..5bd33a8fda 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -25,7 +25,7 @@ import { isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability'; -import { createApiMiddleware } from './middleware/api'; +import { createApiMiddleware, createApiSettingsMiddleware } from './middleware/api'; import { createAuthenticateMiddleware } from './middleware/auth'; // Auth cookie handling is done via AuthCookiePlugin in grafserv import { createCaptchaMiddleware } from './middleware/captcha'; @@ -93,6 +93,7 @@ class Server { const app = express(); const api = createApiMiddleware(effectiveOpts); const databaseAccessPolicy = createDatabaseAccessPolicyMiddleware(effectiveOpts); + const apiSettings = createApiSettingsMiddleware(effectiveOpts); const authenticate = createAuthenticateMiddleware(effectiveOpts); const requestLogger = createRequestLogger({ observabilityEnabled }); @@ -165,6 +166,7 @@ class Server { app.use(requestLogger); app.use(api); app.use(databaseAccessPolicy); + app.use(apiSettings); app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, From 32189c40cd99f6e67f89a4d2132098c517426dc8 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Thu, 27 Aug 2026 00:39:17 +0700 Subject: [PATCH 03/10] fix(graphql): bind private schemas to database policy identity --- graphql/server/README.md | 2 +- .../database-access-policy-pipeline.test.ts | 134 +++++++++++++++++- graphql/server/src/middleware/api.ts | 84 +++++++++-- 3 files changed, 204 insertions(+), 16 deletions(-) diff --git a/graphql/server/README.md b/graphql/server/README.md index e3ca685bd0..6ca39774e0 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -113,7 +113,7 @@ This is a production-only server: every request is resolved through the scoped-r ### Database access policy -Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route identity resolution and before tenant settings, tenant authentication, or request context, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. The option is disabled when unset; when configured, errors and malformed decisions fail closed and decisions are never cached. +Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route identity resolution and before tenant settings, tenant authentication, or request context, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. When the policy is configured, private `X-Schemata` requests also verify every selected schema belongs to the supplied `X-Database-Id` before consulting either the identity cache or the policy, while `X-Meta-Schema` remains the explicit platform-management surface. The option is disabled when unset, which preserves physical-schema-only private routing for standalone tenant installations; when configured, errors and malformed decisions fail closed and decisions are never cached. The function accepts one UUID database id and returns exactly one row: diff --git a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts index 0f8ce6e10e..0aaeeea0b3 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -33,6 +33,7 @@ const mockRegistryResolve = ( ).resolve; const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; +const PLATFORM_DATABASE_ID = '22222222-2222-4222-8222-222222222222'; const TENANT_DATABASE = 'customer_database'; interface PolicyRow { @@ -80,7 +81,12 @@ const matchedRoute = () => ({ } }); -function setupPools(policyRows: PolicyRow[]) { +function setupPools( + policyRows: PolicyRow[], + schemaBindings: Record = { + app_public: DATABASE_ID + } +) { const events: string[] = []; const tenantQuery = jest.fn(async () => { events.push('tenant'); @@ -88,6 +94,16 @@ function setupPools(policyRows: PolicyRow[]) { }); const policyQueue = [...policyRows]; const routingQuery = jest.fn(async (sql: string, params: unknown[]) => { + if (sql.includes('FROM metaschema_public.schema scoped_schema')) { + events.push('schema-binding'); + const requested = params[0] as string[]; + const databaseId = params[1] as string; + return { + rows: requested + .filter((schema_name) => schemaBindings[schema_name] === databaseId) + .map((schema_name) => ({ schema_name })) + }; + } if (sql.includes('information_schema.schemata')) { events.push('schema-resolution'); return { @@ -185,6 +201,117 @@ describe('database access policy pipeline ordering', () => { expect(events.indexOf('tenant')).toBeGreaterThan(events.indexOf('policy')); }); + it('rejects a cold X-Schemata request whose schema belongs to a suspended database but whose id names the platform database', async () => { + const { routingQuery, tenantQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', PLATFORM_DATABASE_ID) + .set('X-Schemata', 'app_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(404); + expect(response.text).toContain('No valid schemas found for the supplied X-Schemata header'); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('platform_private"."database_access') + )).toHaveLength(0); + expect(tenantQuery).not.toHaveBeenCalled(); + expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(svcCache.has(`schemata:${PLATFORM_DATABASE_ID}:app_public`)).toBe(false); + }); + + it('rejects the whole X-Schemata surface when any requested schema belongs to another database', async () => { + const { routingQuery } = setupPools([allowRow], { + app_public: DATABASE_ID, + platform_public: PLATFORM_DATABASE_ID + }); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', DATABASE_ID) + .set('X-Schemata', 'app_public,platform_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(404); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('platform_private"."database_access') + )).toHaveLength(0); + }); + + it('revalidates a warm X-Schemata identity and evicts a cross-database binding before policy', async () => { + const cacheKey = `schemata:${PLATFORM_DATABASE_ID}:app_public`; + svcCache.set(cacheKey, { + dbname: 'routing_database', + anonRole: 'administrator', + roleName: 'administrator', + schema: ['app_public'], + domains: [], + databaseId: PLATFORM_DATABASE_ID, + isPublic: false + }); + const { routingQuery, tenantQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', PLATFORM_DATABASE_ID) + .set('X-Schemata', 'app_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(404); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('FROM metaschema_public.schema scoped_schema') + )).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('platform_private"."database_access') + )).toHaveLength(0); + expect(tenantQuery).not.toHaveBeenCalled(); + expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(svcCache.has(cacheKey)).toBe(false); + }); + + it('keeps X-Meta-Schema as a platform-management surface without applying tenant-schema binding', async () => { + const { routingQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', PLATFORM_DATABASE_ID) + .set('X-Meta-Schema', 'true') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(204); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('FROM metaschema_public.schema scoped_schema') + )).toHaveLength(0); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('platform_private"."database_access') + )).toHaveLength(1); + }); + + it('preserves physical-schema-only X-Schemata routing when the optional access policy is unset', async () => { + const tenantOptions = options(); + delete tenantOptions.api?.databaseAccessPolicyFunction; + const { routingQuery } = setupPools([allowRow], {}); + + const response = await request(pipelineApp(tenantOptions)) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', PLATFORM_DATABASE_ID) + .set('X-Schemata', 'app_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(204); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('FROM metaschema_public.schema scoped_schema') + )).toHaveLength(0); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('platform_private"."database_access') + )).toHaveLength(0); + }); + it.each([ ['scoped route', {}, 'resolve_route'], [ @@ -195,7 +322,7 @@ describe('database access policy pipeline ordering', () => { [ 'X-Schemata', { 'X-Database-Id': DATABASE_ID, 'X-Schemata': 'app_public' }, - 'information_schema.schemata' + 'FROM metaschema_public.schema scoped_schema' ], [ 'X-Meta-Schema', @@ -230,7 +357,8 @@ describe('database access policy pipeline ordering', () => { code: 'DATABASE_BILLING_SUSPENDED', http: 402 }); - expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes(identitySql))).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes(identitySql))) + .toHaveLength(_label === 'X-Schemata' ? 2 : 1); expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes('database_access'))).toHaveLength(2); expect(tenantQuery).toHaveBeenCalledTimes(tenantQueriesAfterAllow); expect(mockRegistryResolve).toHaveBeenCalledTimes(settingsAfterAllow); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 59349b0d71..04e20d8adf 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -313,6 +313,39 @@ const validateSchemata = async (pool: Pool, schemas: string[]): Promise row.schema_name); }; +/** + * Resolve only physical schemas owned by the supplied logical database. + * + * Private X-Schemata requests run with the administrator role, so physical + * schema existence alone is not an ownership boundary. This lookup binds every + * requested schema to the same database identity that the live access policy + * will evaluate. + */ +const validateDatabaseSchemata = async ( + pool: Pool, + schemas: string[], + databaseId: string +): Promise => { + const result = await pool.query<{ schema_name: string }>( + `SELECT DISTINCT scoped_schema.schema_name + FROM metaschema_public.schema scoped_schema + JOIN information_schema.schemata physical_schema + ON physical_schema.schema_name = scoped_schema.schema_name + WHERE scoped_schema.schema_name = ANY($1::text[]) + AND scoped_schema.database_id = $2::uuid`, + [schemas, databaseId] + ); + return result.rows.map((row) => row.schema_name); +}; + +const containsEverySchema = (requested: string[], resolved: string[]): boolean => { + const requestedSet = new Set(requested); + const resolvedSet = new Set(resolved); + return requestedSet.size > 0 && + requestedSet.size === resolvedSet.size && + [...requestedSet].every((schema) => resolvedSet.has(schema)); +}; + const queryByApiName = async ( pool: Pool, opts: ApiOptions, @@ -417,14 +450,6 @@ export const getApiIdentity = async ( req.svc_key = cacheKey; - // Check cache first - if (svcCache.has(cacheKey)) { - log.debug(`Cache HIT for key=${cacheKey}`); - return svcCache.get(cacheKey) as ApiStructure; - } - - log.debug(`Cache MISS for key=${cacheKey}, resolving API`); - const ctx: ResolveContext = { opts, pool, @@ -434,16 +459,52 @@ export const getApiIdentity = async ( headers: getRoutingHeaders(req), host: req.get('host') || '' }; + const mode = determineMode(ctx); + const headerSchemas = ctx.headers.schemata + ? [...new Set(parseCommaSeparatedHeader(ctx.headers.schemata))] + : []; + let databaseSchemas: string[] | null = null; + const liveAccessPolicyConfigured = !!opts.api?.databaseAccessPolicyFunction?.trim(); + + // X-Schemata creates an administrator API over caller-selected schemas. Its + // database binding therefore remains a live routing-plane check, including + // on cache hits, and runs before the billing policy or Graphile can use it. + // Keep this coupled to the optional policy so standalone tenant installs + // retain their existing physical-schema-only routing contract. + if ( + liveAccessPolicyConfigured && + mode === 'schemata-header' && + ctx.headers.databaseId + ) { + const resolvedSchemas = await validateDatabaseSchemata( + pool, + headerSchemas, + ctx.headers.databaseId + ); + if (!containsEverySchema(headerSchemas, resolvedSchemas)) { + svcCache.delete(cacheKey); + return { errorHtml: 'No valid schemas found for the supplied X-Schemata header.' }; + } + const resolvedSet = new Set(resolvedSchemas); + databaseSchemas = headerSchemas.filter((schema) => resolvedSet.has(schema)); + } + + // Check cache only after the live X-Schemata ownership assertion. + if (svcCache.has(cacheKey)) { + log.debug(`Cache HIT for key=${cacheKey}`); + return svcCache.get(cacheKey) as ApiStructure; + } + + log.debug(`Cache MISS for key=${cacheKey}, resolving API`); // Validate schemas upfront for modes that need them const apiOpts = opts.api || {}; - const headerSchemas = ctx.headers.schemata ? parseCommaSeparatedHeader(ctx.headers.schemata) : []; const candidateSchemas = apiOpts.isPublic === false && headerSchemas.length ? [...new Set([...(apiOpts.metaSchemas || []), ...headerSchemas])] : apiOpts.metaSchemas || []; - - const validatedSchemas = await validateSchemata(pool, candidateSchemas); + + const validatedSchemas = databaseSchemas ?? await validateSchemata(pool, candidateSchemas); if (validatedSchemas.length === 0) { const source = headerSchemas.length ? headerSchemas : apiOpts.metaSchemas || []; @@ -454,7 +515,6 @@ export const getApiIdentity = async ( } // Route to appropriate resolver based on mode - const mode = determineMode(ctx); let result: ApiConfigResult; switch (mode) { From e6dd000c07e3b33992280d1292695e221a50bd3d Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Thu, 27 Aug 2026 01:28:26 +0700 Subject: [PATCH 04/10] fix(graphql): map configured execution errors to HTTP --- graphql/env/__tests__/merge.test.ts | 14 ++++ graphql/env/src/env.ts | 6 ++ graphql/server/src/middleware/graphile.ts | 18 ++++- .../graphql-error-http-status-plugin.test.ts | 39 +++++++++++ .../graphql-error-http-status-plugin.ts | 66 +++++++++++++++++++ graphql/types/src/graphile.ts | 5 ++ packages/errors/__tests__/http.test.ts | 2 + packages/errors/src/registry.ts | 12 ++++ 8 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 graphql/server/src/plugins/__tests__/graphql-error-http-status-plugin.test.ts create mode 100644 graphql/server/src/plugins/graphql-error-http-status-plugin.ts diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index 84414c269e..3dff9e3308 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -148,6 +148,20 @@ describe('getEnvOptions', () => { }).api?.databaseAccessPolicyFunction).toBeUndefined(); }); + it('parses the opt-in GraphQL execution-error HTTP status codes', () => { + expect(getGraphQLEnvVars({ + API_GRAPHQL_ERROR_HTTP_STATUS_CODES: + ' DATABASE_BILLING_SUSPENDED, DATABASE_ACCESS_POLICY_UNAVAILABLE, ' + }).api?.graphqlErrorHttpStatusCodes).toEqual([ + 'DATABASE_BILLING_SUSPENDED', + 'DATABASE_ACCESS_POLICY_UNAVAILABLE' + ]); + + expect(getGraphQLEnvVars({ + API_GRAPHQL_ERROR_HTTP_STATUS_CODES: ' , ' + }).api?.graphqlErrorHttpStatusCodes).toBeUndefined(); + }); + it('parses SMS environment variables into typed options', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'devsms', diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 1407d419ef..529b7e2392 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -14,6 +14,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial API_ROUTING_SCHEMA, API_DATABASE_ACCESS_POLICY_FUNCTION, + API_GRAPHQL_ERROR_HTTP_STATUS_CODES, API_IS_PUBLIC, API_EXPOSED_SCHEMAS, API_META_SCHEMAS, @@ -40,6 +41,10 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); const databaseAccessPolicyFunction = API_DATABASE_ACCESS_POLICY_FUNCTION?.trim(); + const graphqlErrorHttpStatusCodes = API_GRAPHQL_ERROR_HTTP_STATUS_CODES + ?.split(',') + .map(code => code.trim()) + .filter(Boolean); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -64,6 +69,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial api: { ...(API_ROUTING_SCHEMA && { routingSchema: API_ROUTING_SCHEMA }), ...(databaseAccessPolicyFunction && { databaseAccessPolicyFunction }), + ...(graphqlErrorHttpStatusCodes?.length && { graphqlErrorHttpStatusCodes }), ...(API_IS_PUBLIC && { isPublic: parseEnvBoolean(API_IS_PUBLIC) }), ...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }), ...(API_META_SCHEMAS && { metaSchemas: API_META_SCHEMAS.split(',').map(s => s.trim()) }), diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..d167dd3e18 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -20,6 +20,7 @@ import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; +import { createGraphQLErrorHttpStatusPlugin } from '../plugins/graphql-error-http-status-plugin'; import type { DatabaseSettings } from '../types'; import { observeGraphileBuild } from './observability/graphile-build-stats'; @@ -167,12 +168,16 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig + compute?: ComputeConfig, + graphqlErrorHttpStatusCodes?: string[] ): GraphileConfig.Preset => { return { extends: [createConstructivePreset(databaseSettings)], plugins: [ AuthCookiePlugin, + ...(graphqlErrorHttpStatusCodes?.length + ? [createGraphQLErrorHttpStatusPlugin(graphqlErrorHttpStatusCodes)] + : []), // Only registered when the compute module is provisioned for this // database — all schema/table names come from the constructive // metaschema (express-context compute module loader); the plugin has @@ -403,7 +408,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute, + opts.api?.graphqlErrorHttpStatusCodes + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/plugins/__tests__/graphql-error-http-status-plugin.test.ts b/graphql/server/src/plugins/__tests__/graphql-error-http-status-plugin.test.ts new file mode 100644 index 0000000000..67cf799769 --- /dev/null +++ b/graphql/server/src/plugins/__tests__/graphql-error-http-status-plugin.test.ts @@ -0,0 +1,39 @@ +import { resolveGraphQLErrorHttpStatus } from '../graphql-error-http-status-plugin'; + +const configuredCodes = new Set([ + 'DATABASE_BILLING_SUSPENDED', + 'DATABASE_ACCESS_POLICY_UNAVAILABLE' +]); + +const response = (value: unknown): Buffer => Buffer.from(JSON.stringify(value), 'utf8'); + +describe('GraphQL execution-error HTTP status mapping', () => { + it('maps configured registered codes to their exact HTTP status', () => { + expect(resolveGraphQLErrorHttpStatus(response({ + errors: [{ extensions: { code: 'DATABASE_BILLING_SUSPENDED' } }] + }), configuredCodes)).toBe(402); + + expect(resolveGraphQLErrorHttpStatus(response({ + errors: [{ extensions: { code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE' } }] + }), configuredCodes)).toBe(503); + }); + + it('prefers service failure when an operation contains both errors', () => { + expect(resolveGraphQLErrorHttpStatus(response({ + errors: [ + { extensions: { code: 'DATABASE_BILLING_SUSPENDED' } }, + { extensions: { code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE' } } + ] + }), configuredCodes)).toBe(503); + }); + + it('leaves unconfigured, successful, and malformed responses unchanged', () => { + expect(resolveGraphQLErrorHttpStatus(response({ + errors: [{ extensions: { code: 'LIMIT_REACHED' } }] + }), configuredCodes)).toBeUndefined(); + expect(resolveGraphQLErrorHttpStatus(response({ data: { ok: true } }), configuredCodes)) + .toBeUndefined(); + expect(resolveGraphQLErrorHttpStatus(Buffer.from('not-json'), configuredCodes)) + .toBeUndefined(); + }); +}); diff --git a/graphql/server/src/plugins/graphql-error-http-status-plugin.ts b/graphql/server/src/plugins/graphql-error-http-status-plugin.ts new file mode 100644 index 0000000000..1f4f6f4793 --- /dev/null +++ b/graphql/server/src/plugins/graphql-error-http-status-plugin.ts @@ -0,0 +1,66 @@ +import { httpStatusFor } from '@constructive-io/errors'; +import type { BufferResult } from 'grafserv'; +import type { GraphileConfig } from 'graphile-config'; + +interface GraphQLResponse { + errors?: Array<{ extensions?: { code?: unknown } }>; +} + +/** + * Resolve an HTTP status from a serialized GraphQL execution result. + * + * Only explicitly configured, registered codes participate. A 5xx response + * wins over a 4xx response when one operation returns more than one error. + */ +export const resolveGraphQLErrorHttpStatus = ( + buffer: Buffer, + configuredCodes: ReadonlySet +): number | undefined => { + let payload: GraphQLResponse; + try { + payload = JSON.parse(buffer.toString('utf8')) as GraphQLResponse; + } catch { + return undefined; + } + + const statuses = (payload.errors ?? []) + .map(error => error.extensions?.code) + .filter((code): code is string => typeof code === 'string' && configuredCodes.has(code)) + .map(code => httpStatusFor(code)) + .filter(result => result.mapped) + .map(result => result.status); + + if (statuses.length === 0) return undefined; + return statuses.find(status => status >= 500) ?? Math.max(...statuses); +}; + +/** + * Opt-in grafserv transport mapping for domain errors with an explicit HTTP + * contract. Standard GraphQL HTTP 200 behavior is unchanged when not enabled. + */ +export const createGraphQLErrorHttpStatusPlugin = ( + configuredCodes: readonly string[] +): GraphileConfig.Plugin => { + const allowed = new Set(configuredCodes); + + return { + name: 'GraphQLErrorHttpStatusPlugin', + version: '1.0.0', + grafserv: { + middleware: { + processRequest: { + callback: async next => { + const result = await next(); + if (!result || result.type !== 'buffer') return result; + + const bufferResult = result as BufferResult; + const statusCode = resolveGraphQLErrorHttpStatus(bufferResult.buffer, allowed); + return statusCode === undefined + ? result + : { ...bufferResult, statusCode }; + } + } + } + } + }; +}; diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index a97ea1cc38..3e21b977e2 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -41,6 +41,11 @@ export interface ApiOptions { * resolved database may accept a new request. */ databaseAccessPolicyFunction?: string; + /** + * Optional registry codes whose GraphQL execution errors should set the HTTP + * response status. Unset preserves the standard HTTP 200 execution response. + */ + graphqlErrorHttpStatusCodes?: string[]; /** Schemas containing metadata tables */ metaSchemas?: string[]; /** diff --git a/packages/errors/__tests__/http.test.ts b/packages/errors/__tests__/http.test.ts index fd73f58d1a..2ab2ec89dc 100644 --- a/packages/errors/__tests__/http.test.ts +++ b/packages/errors/__tests__/http.test.ts @@ -23,6 +23,8 @@ describe('httpStatusFor', () => { expect(httpStatusFor('ACCOUNT_DISABLED')).toEqual({ status: 403, mapped: true }); expect(httpStatusFor('ACCOUNT_EXISTS')).toEqual({ status: 409, mapped: true }); expect(httpStatusFor('INVALID_CREDENTIALS')).toEqual({ status: 401, mapped: true }); + expect(httpStatusFor('DATABASE_BILLING_SUSPENDED')).toEqual({ status: 402, mapped: true }); + expect(httpStatusFor('DATABASE_ACCESS_POLICY_UNAVAILABLE')).toEqual({ status: 503, mapped: true }); expect(reported).toEqual([]); }); diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 64de8fd5a1..caa7c84597 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -288,6 +288,18 @@ export const registry = { message: 'You have reached a plan limit for this resource.', positional: ['resource', 'limit'] }), + DATABASE_BILLING_SUSPENDED: defineError({ + code: 'DATABASE_BILLING_SUSPENDED', + class: 'public', + http: 402, + message: 'Database access is paused until billing is restored.' + }), + DATABASE_ACCESS_POLICY_UNAVAILABLE: defineError({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + class: 'public', + http: 503, + message: 'Database access could not be verified. Please try again.' + }), RATE_LIMITED: defineError({ code: 'RATE_LIMITED', class: 'public', From b20261ef21e0860d5bc768f556bb3735bbf066c3 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 28 Aug 2026 19:03:15 +0700 Subject: [PATCH 05/10] fix(graphql): harden database access policy boundary --- graphql/env/README.md | 4 + .../__snapshots__/merge.test.ts.snap | 2 + graphql/env/__tests__/merge.test.ts | 10 ++ graphql/env/src/env.ts | 6 + graphql/server/README.md | 8 +- .../src/middleware/__tests__/api.test.ts | 37 +++-- .../database-access-policy-pipeline.test.ts | 90 +++++++++++- .../__tests__/database-access-policy.test.ts | 137 +++++++++++++++--- graphql/server/src/middleware/api.ts | 96 ++++++++++-- .../src/middleware/database-access-policy.ts | 99 ++++++++++++- .../server/src/middleware/error-handler.ts | 9 +- graphql/server/src/server.ts | 16 +- graphql/types/src/graphile.ts | 6 + packages/errors/src/registry.ts | 6 + 14 files changed, 459 insertions(+), 67 deletions(-) diff --git a/graphql/env/README.md b/graphql/env/README.md index 5dff104656..6409e8f1a0 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -52,6 +52,8 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### API Configuration - `API_ROUTING_SCHEMA` - Schema containing the compiled `resolve_route()` resolver (production routing always resolves through it) - `API_DATABASE_ACCESS_POLICY_FUNCTION` - Optional schema-qualified function that authorizes requests for the resolved database +- `API_DATABASE_ACCESS_POLICY_POOL_MAX` - Maximum dedicated connections for access-policy checks (default `2`, maximum `8`) +- `API_DATABASE_ACCESS_POLICY_TIMEOUT_MS` - Connection and query deadline for access-policy checks (default `1500`, range `100`-`30000`) - `API_IS_PUBLIC` - Whether API is public - `API_EXPOSED_SCHEMAS` - Comma-separated list of exposed schemas - `API_META_SCHEMAS` - Comma-separated list of meta schemas @@ -75,6 +77,8 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: anonRole: 'administrator', roleName: 'administrator', isPublic: true, + databaseAccessPolicyPoolMax: 2, + databaseAccessPolicyTimeoutMs: 1500, metaSchemas: ['routing_public', 'metaschema_public', 'metaschema_modules_public'], routingSchema: 'routing_public' } diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..865a290418 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -4,6 +4,8 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and { "api": { "anonRole": "env_anon", + "databaseAccessPolicyPoolMax": 2, + "databaseAccessPolicyTimeoutMs": 1500, "exposedSchemas": [ "public", "app", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index 3dff9e3308..2587a4028e 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -148,6 +148,16 @@ describe('getEnvOptions', () => { }).api?.databaseAccessPolicyFunction).toBeUndefined(); }); + it('parses database access policy resource bounds', () => { + const api = getGraphQLEnvVars({ + API_DATABASE_ACCESS_POLICY_POOL_MAX: '3', + API_DATABASE_ACCESS_POLICY_TIMEOUT_MS: '2400' + }).api; + + expect(api?.databaseAccessPolicyPoolMax).toBe(3); + expect(api?.databaseAccessPolicyTimeoutMs).toBe(2400); + }); + it('parses the opt-in GraphQL execution-error HTTP status codes', () => { expect(getGraphQLEnvVars({ API_GRAPHQL_ERROR_HTTP_STATUS_CODES: diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 529b7e2392..59a666f3d8 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -14,6 +14,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial API_ROUTING_SCHEMA, API_DATABASE_ACCESS_POLICY_FUNCTION, + API_DATABASE_ACCESS_POLICY_POOL_MAX, + API_DATABASE_ACCESS_POLICY_TIMEOUT_MS, API_GRAPHQL_ERROR_HTTP_STATUS_CODES, API_IS_PUBLIC, API_EXPOSED_SCHEMAS, @@ -41,6 +43,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); const databaseAccessPolicyFunction = API_DATABASE_ACCESS_POLICY_FUNCTION?.trim(); + const databaseAccessPolicyPoolMax = parseEnvNumber(API_DATABASE_ACCESS_POLICY_POOL_MAX); + const databaseAccessPolicyTimeoutMs = parseEnvNumber(API_DATABASE_ACCESS_POLICY_TIMEOUT_MS); const graphqlErrorHttpStatusCodes = API_GRAPHQL_ERROR_HTTP_STATUS_CODES ?.split(',') .map(code => code.trim()) @@ -69,6 +73,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial api: { ...(API_ROUTING_SCHEMA && { routingSchema: API_ROUTING_SCHEMA }), ...(databaseAccessPolicyFunction && { databaseAccessPolicyFunction }), + ...(databaseAccessPolicyPoolMax !== undefined && { databaseAccessPolicyPoolMax }), + ...(databaseAccessPolicyTimeoutMs !== undefined && { databaseAccessPolicyTimeoutMs }), ...(graphqlErrorHttpStatusCodes?.length && { graphqlErrorHttpStatusCodes }), ...(API_IS_PUBLIC && { isPublic: parseEnvBoolean(API_IS_PUBLIC) }), ...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }), diff --git a/graphql/server/README.md b/graphql/server/README.md index 6ca39774e0..06aefa312e 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -109,11 +109,13 @@ This is a production-only server: every request is resolved through the scoped-r - `X-Api-Name` + `X-Database-Id` - `X-Schemata` + `X-Database-Id` - `X-Meta-Schema` + `X-Database-Id` -- A resolved database id is always required. There is no default database, so a request that resolves without one is rejected as `NO_DATABASE_ID` (HTTP 500) when no access policy is configured, or `DATABASE_ACCESS_POLICY_UNAVAILABLE` (HTTP 503) when the live policy must fail closed. +- A resolved database id is always required. Private routing selectors require a valid UUID in `X-Database-Id`; missing or malformed identities are rejected as `INVALID_DATABASE_IDENTITY` (HTTP 400) before PostgreSQL lookup or body parsing. A scoped route that resolves without an identity is rejected as `NO_DATABASE_ID` (HTTP 500) when no access policy is configured, or `DATABASE_ACCESS_POLICY_UNAVAILABLE` (HTTP 503) when the live policy must fail closed. ### Database access policy -Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through its configured routing database after route identity resolution and before tenant settings, tenant authentication, or request context, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. When the policy is configured, private `X-Schemata` requests also verify every selected schema belongs to the supplied `X-Database-Id` before consulting either the identity cache or the policy, while `X-Meta-Schema` remains the explicit platform-management surface. The option is disabled when unset, which preserves physical-schema-only private routing for standalone tenant installations; when configured, errors and malformed decisions fail closed and decisions are never cached. +Set `API_DATABASE_ACCESS_POLICY_FUNCTION` to a lowercase, schema-qualified PostgreSQL function when new requests must pass a control-plane access decision. The server calls the function through a dedicated pool after route identity resolution and before multipart parsing, tenant settings, tenant authentication, or request context, including for private `X-Api-Name`, `X-Schemata`, and `X-Meta-Schema` routes. The pool defaults to two connections and applies a 1500 ms connection, client-query, and PostgreSQL statement deadline. When the policy is configured, private `X-Schemata` requests also verify every selected schema belongs to the supplied `X-Database-Id` before consulting either the identity cache or the policy, while `X-Meta-Schema` remains the explicit platform-management surface. The option is disabled when unset, which preserves physical-schema-only private routing for standalone tenant installations; when configured, errors, timeouts, and malformed decisions fail closed and decisions are never cached. + +The exported `getApiConfig()` compatibility helper does not own an HTTP policy lifecycle, so it fails closed before PostgreSQL whenever `API_DATABASE_ACCESS_POLICY_FUNCTION` is configured. Policy-aware servers must use the ordered identity, policy, and settings middleware pipeline; behavior without a configured policy is unchanged. The function accepts one UUID database id and returns exactly one row: @@ -146,6 +148,8 @@ Configuration is merged from defaults, config files, and env vars via `@construc | `FEATURES_POSTGIS` | Enable PostGIS support | `true` | | `API_ROUTING_SCHEMA` | Schema containing `resolve_route()` | `routing_public` | | `API_DATABASE_ACCESS_POLICY_FUNCTION` | Schema-qualified resolved-database policy function | unset | +| `API_DATABASE_ACCESS_POLICY_POOL_MAX` | Dedicated policy-pool connection limit (`1`-`8`) | `2` | +| `API_DATABASE_ACCESS_POLICY_TIMEOUT_MS` | Policy connection/query deadline in ms (`100`-`30000`) | `1500` | | `API_IS_PUBLIC` | Serve public APIs only | `true` | | `API_EXPOSED_SCHEMAS` | Additional schemas to expose | empty | | `API_META_SCHEMAS` | Meta schemas to query | `routing_public,metaschema_public,metaschema_modules_public` | diff --git a/graphql/server/src/middleware/__tests__/api.test.ts b/graphql/server/src/middleware/__tests__/api.test.ts index dfc0020c34..bc15efcf29 100644 --- a/graphql/server/src/middleware/__tests__/api.test.ts +++ b/graphql/server/src/middleware/__tests__/api.test.ts @@ -14,9 +14,10 @@ import type { Pool } from 'pg'; import { getPgPool } from 'pg-cache'; import type { ApiOptions } from '../../types'; -import { getApiIdentity, getSvcKey } from '../api'; +import { getApiConfig, getApiIdentity, getSvcKey } from '../api'; const mockGetPgPool = getPgPool as jest.MockedFunction; +const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; const createRequest = (headers: Record): Request => { const normalized = new Map( @@ -53,12 +54,12 @@ describe('api middleware routing priority', () => { it('uses X-Api-Name before X-Schemata when building private service keys', () => { const req = createRequest({ host: 'admin.localhost', - 'X-Database-Id': 'db-123', + 'X-Database-Id': DATABASE_ID, 'X-Api-Name': 'customer-api', 'X-Schemata': 'app_public' }); - expect(getSvcKey(createPrivateOptions(), req)).toBe('api:db-123:customer-api'); + expect(getSvcKey(createPrivateOptions(), req)).toBe(`api:${DATABASE_ID}:customer-api`); }); it('uses the same X-Api-Name priority when resolving and caching API identity', async () => { @@ -71,11 +72,11 @@ describe('api middleware routing priority', () => { }; } - if (params[0] === 'db-123' && params[1] === 'customer-api') { + if (params[0] === DATABASE_ID && params[1] === 'customer-api') { return { rows: [{ api_id: 'api-123', - database_id: 'db-123', + database_id: DATABASE_ID, dbname: 'tenant_db', role_name: 'api_role', anon_role: 'api_anon', @@ -92,26 +93,42 @@ describe('api middleware routing priority', () => { const req = createRequest({ host: 'admin.localhost', - 'X-Database-Id': 'db-123', + 'X-Database-Id': DATABASE_ID, 'X-Api-Name': 'customer-api', 'X-Schemata': 'app_public' }); const result = await getApiIdentity(createPrivateOptions(), req); - expect(req.svc_key).toBe('api:db-123:customer-api'); + expect(req.svc_key).toBe(`api:${DATABASE_ID}:customer-api`); expect(result).toMatchObject({ apiId: 'api-123', dbname: 'tenant_db', anonRole: 'api_anon', roleName: 'api_role', schema: ['api_public'], - databaseId: 'db-123', + databaseId: DATABASE_ID, isPublic: false }); - expect(svcCache.get('api:db-123:customer-api')).toBe(result); + expect(svcCache.get(`api:${DATABASE_ID}:customer-api`)).toBe(result); expect(query.mock.calls).toEqual(expect.arrayContaining([ - [expect.stringContaining('FROM "routing_public".apis'), ['db-123', 'customer-api']] + [expect.stringContaining('FROM "routing_public".apis'), [DATABASE_ID, 'customer-api']] ])); }); + + it('fails closed before PostgreSQL when the direct config helper cannot apply a configured policy', async () => { + const opts = createPrivateOptions(); + opts.api!.databaseAccessPolicyFunction = 'platform_private.database_access'; + const req = createRequest({ + host: 'admin.localhost', + 'X-Database-Id': DATABASE_ID, + 'X-Api-Name': 'customer-api' + }); + + await expect(getApiConfig(opts, req)).rejects.toMatchObject({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + statusCode: 503 + }); + expect(mockGetPgPool).not.toHaveBeenCalled(); + }); }); diff --git a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts index 0aaeeea0b3..33416d651a 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -16,21 +16,26 @@ jest.mock('@constructive-io/express-context', () => ({ import { createDefaultRegistry } from '@constructive-io/express-context'; import { svcCache } from '@pgpmjs/server-utils'; -import express from 'express'; +import express, { type NextFunction, type Request, type Response } from 'express'; import type { Pool } from 'pg'; import { getPgPool } from 'pg-cache'; import request from 'supertest'; import type { ApiOptions } from '../../types'; import { createApiMiddleware, createApiSettingsMiddleware } from '../api'; -import { createDatabaseAccessPolicyMiddleware } from '../database-access-policy'; +import { createDatabaseAccessPolicyMiddleware as createDatabaseAccessPolicyMiddlewareImpl } from '../database-access-policy'; import { errorHandler } from '../error-handler'; const mockGetPgPool = getPgPool as jest.MockedFunction; +let activePolicyPool: Pool; +const mockCreatePolicyPool = jest.fn(() => activePolicyPool); +const createDatabaseAccessPolicyMiddleware = (opts: ApiOptions) => + createDatabaseAccessPolicyMiddlewareImpl(opts, { createPool: mockCreatePolicyPool }); const mockCreateDefaultRegistry = createDefaultRegistry as jest.MockedFunction; const mockRegistryResolve = ( mockCreateDefaultRegistry.mock.results[0]?.value as { resolve: jest.Mock } ).resolve; +const multipartParser = jest.fn((_req: Request, _res: Response, next: NextFunction) => next()); const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; const PLATFORM_DATABASE_ID = '22222222-2222-4222-8222-222222222222'; @@ -137,6 +142,7 @@ function setupPools( const routingPool = { query: routingQuery } as unknown as Pool; const tenantPool = { query: tenantQuery } as unknown as Pool; + activePolicyPool = routingPool; mockGetPgPool.mockImplementation((pgOptions) => ( pgOptions?.database === TENANT_DATABASE ? tenantPool : routingPool )); @@ -148,6 +154,7 @@ function pipelineApp(apiOptions = options()) { const app = express(); app.use(createApiMiddleware(apiOptions)); app.use(createDatabaseAccessPolicyMiddleware(apiOptions)); + app.use('/graphql', multipartParser); app.use(createApiSettingsMiddleware(apiOptions)); app.use((_req, res) => res.status(204).end()); app.use(errorHandler); @@ -185,6 +192,7 @@ describe('database access policy pipeline ordering', () => { }); expect(tenantQuery).not.toHaveBeenCalled(); expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(multipartParser).not.toHaveBeenCalled(); }); it('hydrates tenant settings only after an allowed policy decision', async () => { @@ -196,11 +204,78 @@ describe('database access policy pipeline ordering', () => { .send({ query: '{ __typename }' }); expect(response.status).toBe(204); + expect(multipartParser).toHaveBeenCalledTimes(1); expect(tenantQuery).toHaveBeenCalled(); expect(events.indexOf('policy')).toBeGreaterThan(events.indexOf('route-resolution')); expect(events.indexOf('tenant')).toBeGreaterThan(events.indexOf('policy')); }); + it('rejects malformed private database identity before PostgreSQL or multipart parsing', async () => { + const { routingQuery, tenantQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Database-Id', 'not-a-uuid') + .set('X-Api-Name', 'customer') + .set('Content-Type', 'multipart/form-data; boundary=test-boundary') + .send('--test-boundary--'); + + expect(response.status).toBe(400); + expect(response.body.errors[0]).toMatchObject({ + extensions: { + code: 'INVALID_DATABASE_IDENTITY', + http: 400 + } + }); + expect(routingQuery).not.toHaveBeenCalled(); + expect(tenantQuery).not.toHaveBeenCalled(); + expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(multipartParser).not.toHaveBeenCalled(); + }); + + it('uses the stable REST JSON envelope for malformed identity without an Accept header', async () => { + const { routingQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp()) + .get('/fn/invocations/invocation-1') + .set('Host', 'admin.example.com') + .set('X-Database-Id', 'not-a-uuid') + .set('X-Api-Name', 'customer'); + + expect(response.status).toBe(400); + expect(response.type).toBe('application/json'); + expect(response.body.error).toMatchObject({ + code: 'INVALID_DATABASE_IDENTITY', + message: 'X-Database-Id must be a valid UUID when private routing headers are used.' + }); + expect(routingQuery).not.toHaveBeenCalled(); + }); + + it('ignores spoofed private routing headers on the public API surface', async () => { + const publicOptions = options(); + publicOptions.api!.isPublic = true; + const { routingQuery } = setupPools([allowRow]); + + const response = await request(pipelineApp(publicOptions)) + .post('/graphql') + .set('Host', 'api.example.com') + .set('X-Database-Id', PLATFORM_DATABASE_ID) + .set('X-Api-Name', 'customer') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(204); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('resolve_route') + )).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql, params]) => + String(sql).includes('database_access') && params?.[0] === DATABASE_ID + )).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('FROM "routing_public".apis') + )).toHaveLength(0); + }); + it('rejects a cold X-Schemata request whose schema belongs to a suspended database but whose id names the platform database', async () => { const { routingQuery, tenantQuery } = setupPools([allowRow]); @@ -364,7 +439,7 @@ describe('database access policy pipeline ordering', () => { expect(mockRegistryResolve).toHaveBeenCalledTimes(settingsAfterAllow); }); - it('fails closed with the policy-unavailable contract when private routing omits database identity', async () => { + it('rejects private routing without database identity before PostgreSQL or multipart parsing', async () => { const { routingQuery, tenantQuery } = setupPools([allowRow]); const response = await request(pipelineApp()) @@ -373,13 +448,14 @@ describe('database access policy pipeline ordering', () => { .set('X-Schemata', 'app_public') .send({ query: '{ __typename }' }); - expect(response.status).toBe(503); + expect(response.status).toBe(400); expect(response.body.errors[0].extensions).toMatchObject({ - code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', - http: 503 + code: 'INVALID_DATABASE_IDENTITY', + http: 400 }); - expect(routingQuery.mock.calls.some(([sql]) => String(sql).includes('database_access'))).toBe(false); + expect(routingQuery).not.toHaveBeenCalled(); expect(tenantQuery).not.toHaveBeenCalled(); expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(multipartParser).not.toHaveBeenCalled(); }); }); diff --git a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts index e1e794b3d8..2895fc05f6 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts @@ -1,19 +1,16 @@ -jest.mock('pg-cache', () => ({ - getPgPool: jest.fn() -})); - import { ConstructiveError } from '@constructive-io/errors'; import express, { type NextFunction, type Request, type Response } from 'express'; import type { Pool } from 'pg'; -import { getPgPool } from 'pg-cache'; import request from 'supertest'; import { ApiError } from '../../errors/api-errors'; import type { ApiOptions } from '../../types'; -import { createDatabaseAccessPolicyMiddleware } from '../database-access-policy'; +import { createDatabaseAccessPolicyMiddleware as createDatabaseAccessPolicyMiddlewareImpl } from '../database-access-policy'; import { errorHandler } from '../error-handler'; -const mockGetPgPool = getPgPool as jest.MockedFunction; +const mockCreatePool = jest.fn(); +const createDatabaseAccessPolicyMiddleware = (opts: ApiOptions) => + createDatabaseAccessPolicyMiddlewareImpl(opts, { createPool: mockCreatePool }); const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; interface TestPolicyRow { @@ -79,20 +76,26 @@ describe('database access policy middleware', () => { await middleware(createRequest(), createResponse().res, next); - expect(mockGetPgPool).not.toHaveBeenCalled(); + expect(mockCreatePool).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledWith(); }); it('queries the routing pool with the resolved database id and permits an allowed request', async () => { const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const opts = options('platform_private.database_access'); const middleware = createDatabaseAccessPolicyMiddleware(opts); const next = jest.fn(); await middleware(createRequest(), createResponse().res, next); - expect(mockGetPgPool).toHaveBeenCalledWith(opts.pg); + expect(mockCreatePool).toHaveBeenCalledWith(expect.objectContaining({ + database: 'routing_database', + max: 2, + connectionTimeoutMillis: 1500, + statement_timeout: 1500, + query_timeout: 1500 + })); expect(query).toHaveBeenCalledWith( expect.stringContaining('from "platform_private"."database_access"($1::uuid)'), [DATABASE_ID] @@ -100,13 +103,48 @@ describe('database access policy middleware', () => { expect(next).toHaveBeenCalledWith(); }); + it('applies configured pool and query bounds and closes the dedicated pool once', async () => { + const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); + const end = jest.fn().mockResolvedValue(undefined); + mockCreatePool.mockReturnValue({ query, end } as unknown as Pool); + const opts = options('platform_private.database_access'); + opts.api!.databaseAccessPolicyPoolMax = 3; + opts.api!.databaseAccessPolicyTimeoutMs = 2400; + const middleware = createDatabaseAccessPolicyMiddleware(opts); + + expect(mockCreatePool).toHaveBeenCalledWith(expect.objectContaining({ + max: 3, + connectionTimeoutMillis: 2400, + statement_timeout: 2400, + query_timeout: 2400 + })); + + await middleware.close(); + await middleware.close(); + expect(end).toHaveBeenCalledTimes(1); + }); + + it('fails closed without creating a pool when policy resource bounds are invalid', async () => { + const opts = options('platform_private.database_access'); + opts.api!.databaseAccessPolicyPoolMax = 0; + const middleware = createDatabaseAccessPolicyMiddleware(opts); + const { res, status, json } = createResponse(); + + await middleware(createRequest('/graphql'), res, jest.fn()); + + expect(mockCreatePool).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(503); + expect(json.mock.calls[0][0].errors[0].extensions.code) + .toBe('DATABASE_ACCESS_POLICY_UNAVAILABLE'); + }); + it.each([ ['X-Api-Name', { 'x-api-name': 'customer' }], ['X-Schemata', { 'x-schemata': 'app_public' }], ['X-Meta-Schema', { 'x-meta-schema': 'tenant_meta' }] ])('does not bypass a private %s request', async (_label, headers) => { const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access', false) ); @@ -120,7 +158,7 @@ describe('database access policy middleware', () => { it('returns the exact GraphQL 402 contract when access is denied', async () => { const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -143,9 +181,12 @@ describe('database access policy middleware', () => { expect(next).not.toHaveBeenCalled(); }); - it('uses the REST error handler status and envelope outside GraphQL', async () => { + it.each([ + '/fn/invocations/invocation-1', + '/v1/threads/thread-1/messages' + ])('uses the stable REST JSON envelope for %s without an Accept header', async (path) => { const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -159,11 +200,10 @@ describe('database access policy middleware', () => { app.use((_req, res) => res.status(204).end()); app.use(errorHandler); - const response = await request(app) - .post('/fn/example') - .set('Accept', 'application/json'); + const response = await request(app).get(path); expect(response.status).toBe(402); + expect(response.type).toBe('application/json'); expect(response.body).toEqual({ error: { code: denyRow.code, @@ -173,6 +213,36 @@ describe('database access policy middleware', () => { }); }); + it('uses the stable REST JSON 503 envelope when policy evaluation fails without an Accept header', async () => { + const query = jest.fn().mockRejectedValue(new Error('connection lost')); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const app = express(); + app.use((req: Request, _res: Response, next: NextFunction) => { + req.databaseId = DATABASE_ID; + req.requestId = 'request-503'; + next(); + }); + app.use(middleware); + app.use((_req, res) => res.status(204).end()); + app.use(errorHandler); + + const response = await request(app) + .get('/fn/invocations/invocation-1'); + + expect(response.status).toBe(503); + expect(response.type).toBe('application/json'); + expect(response.body).toEqual({ + error: { + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + message: 'Database access policy is temporarily unavailable.', + requestId: 'request-503' + } + }); + }); + it('fails closed without querying when the configured function name is unsafe', async () => { const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access;drop table users') @@ -182,7 +252,7 @@ describe('database access policy middleware', () => { await middleware(createRequest('/graphql'), res, next); - expect(mockGetPgPool).not.toHaveBeenCalled(); + expect(mockCreatePool).not.toHaveBeenCalled(); expect(status).toHaveBeenCalledWith(503); const error = json.mock.calls[0][0].errors[0]; expect(error.message).toBe('Database access policy is temporarily unavailable.'); @@ -204,7 +274,7 @@ describe('database access policy middleware', () => { ['an out-of-range status', [{ ...denyRow, http_status: 200 }]] ])('fails closed when the policy returns %s', async (_label, rows) => { const query = jest.fn().mockResolvedValue({ rows }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -223,7 +293,7 @@ describe('database access policy middleware', () => { it('fails closed when policy evaluation throws', async () => { const query = jest.fn().mockRejectedValue(new Error('connection lost')); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -238,9 +308,30 @@ describe('database access policy middleware', () => { expect(next).not.toHaveBeenCalled(); }); + it('returns the stable 503 contract when the policy query reaches its deadline', async () => { + const query = jest.fn().mockRejectedValue(new Error('Query read timeout')); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, status, json } = createResponse(); + + await middleware(createRequest('/graphql'), res, jest.fn()); + + expect(status).toHaveBeenCalledWith(503); + expect(json.mock.calls[0][0].errors[0]).toMatchObject({ + message: 'Database access policy is temporarily unavailable.', + extensions: { + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + class: 'internal', + http: 503 + } + }); + }); + it('fails closed when API resolution did not supply a database id', async () => { const query = jest.fn(); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -260,7 +351,7 @@ describe('database access policy middleware', () => { it('evaluates the policy again for every request', async () => { const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); @@ -275,7 +366,7 @@ describe('database access policy middleware', () => { it('passes a REST denial to the canonical typed error path', async () => { const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); - mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); const middleware = createDatabaseAccessPolicyMiddleware( options('platform_private.database_access') ); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 04e20d8adf..3b941a4a9a 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -1,5 +1,6 @@ import './types'; +import { ConstructiveError,errors } from '@constructive-io/errors'; import { createDefaultRegistry, LoaderContext, @@ -14,6 +15,8 @@ import { getPgPool } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; +import { ApiError as HttpApiError,isApiError as isHttpApiError } from '../errors/api-errors'; +import { respondWithGraphQLError } from '../errors/graphql-response'; import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, DatabaseSettings, PubkeyChallengeSettings, RlsModule, WebauthnSettings } from '../types'; import { getRoutingSchema, isValidSchemaName, resolveRoute, routeToApiStructure } from './routing'; @@ -222,6 +225,8 @@ const assertDatabaseId = (result: ApiStructure): void => { const parseCommaSeparatedHeader = (value: string): string[] => value.split(',').map((s) => s.trim()).filter(Boolean); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const getPrivateHeaderMode = (headers: RoutingHeaders): PrivateHeaderMode | null => { if (headers.apiName) return 'api-name-header'; if (headers.schemata) return 'schemata-header'; @@ -229,12 +234,46 @@ const getPrivateHeaderMode = (headers: RoutingHeaders): PrivateHeaderMode | null return null; }; -const getRoutingHeaders = (req: Request): RoutingHeaders => ({ - schemata: req.get('X-Schemata'), - apiName: req.get('X-Api-Name'), - metaSchema: req.get('X-Meta-Schema'), - databaseId: req.get('X-Database-Id') -}); +const invalidDatabaseIdentity = (): HttpApiError => { + const error = errors.INVALID_DATABASE_IDENTITY(); + return new HttpApiError(error.code, error.http, error.message); +}; + +const directConfigPolicyUnavailable = (): HttpApiError => { + const error = errors.DATABASE_ACCESS_POLICY_UNAVAILABLE(); + return new HttpApiError(error.code, error.http, error.message); +}; + +/** + * Private routing selectors are trusted only when they carry a syntactically + * valid database identity. Validate this before route lookup, cache access, or + * request-body parsing so malformed selectors cannot reach PostgreSQL or the + * multipart parser. + */ +const validatePrivateDatabaseIdentity = ( + opts: ApiOptions, + headers: RoutingHeaders +): void => { + if (opts.api?.isPublic !== false || !getPrivateHeaderMode(headers)) return; + if (!headers.databaseId || !UUID_PATTERN.test(headers.databaseId)) { + throw invalidDatabaseIdentity(); + } +}; + +const normalizedHeader = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized || undefined; +}; + +const getRoutingHeaders = (req: Request): RoutingHeaders => { + const databaseId = normalizedHeader(req.get('X-Database-Id')); + return { + schemata: normalizedHeader(req.get('X-Schemata')), + apiName: normalizedHeader(req.get('X-Api-Name')), + metaSchema: normalizedHeader(req.get('X-Meta-Schema')), + databaseId: databaseId?.toLowerCase() + }; +}; const getUrlDomains = (req: Request): { domain: string; subdomains: string[] } => { const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; @@ -443,6 +482,8 @@ export const getApiIdentity = async ( opts: ApiOptions, req: Request ): Promise => { + const headers = getRoutingHeaders(req); + validatePrivateDatabaseIdentity(opts, headers); const pool = getPgPool(opts.pg); const { domain, subdomains } = getUrlDomains(req); const subdomain = getSubdomain(subdomains); @@ -456,7 +497,7 @@ export const getApiIdentity = async ( domain, subdomain, cacheKey, - headers: getRoutingHeaders(req), + headers, host: req.get('host') || '' }; const mode = determineMode(ctx); @@ -545,16 +586,21 @@ export const getApiIdentity = async ( }; /** - * Resolve API identity for callers that use this helper directly. + * Resolve and hydrate an API for callers that use this helper directly. * - * The server middleware uses `getApiIdentity` so a configured live policy can - * turn a missing identity into its stable fail-closed response. Direct callers - * keep the historical no-default-database assertion. + * This helper cannot evaluate request-bound access policy safely. It preserves + * its historical behavior when no policy is configured, but fails closed before + * PostgreSQL when a live policy is enabled. Policy-aware callers must use the + * server's identity -> policy -> settings middleware pipeline. */ export const getApiConfig = async ( opts: ApiOptions, req: Request ): Promise => { + if (opts.api?.databaseAccessPolicyFunction?.trim()) { + throw directConfigPolicyUnavailable(); + } + const result = await getApiIdentity(opts, req); if (result && !isApiError(result)) { assertDatabaseId(result); @@ -567,6 +613,29 @@ export const getApiConfig = async ( // Express Middleware // ============================================================================= +const rejectApiError = ( + req: Request, + res: Response, + next: NextFunction, + error: HttpApiError +): void => { + if (req.path === '/graphql' || req.path === '/graphql/') { + respondWithGraphQLError( + res, + new ConstructiveError({ + code: error.code, + message: error.message, + errorClass: 'public', + http: error.statusCode + }), + error.statusCode + ); + return; + } + + next(error); +}; + export const createApiMiddleware = (opts: ApiOptions) => { return async (req: Request, res: Response, next: NextFunction): Promise => { log.debug(`[api-middleware] ${req.method} ${req.path}`); @@ -594,6 +663,11 @@ export const createApiMiddleware = (opts: ApiOptions) => { } catch (error: unknown) { const err = error as Error & { code?: string }; + if (isHttpApiError(err)) { + rejectApiError(req, res, next, err); + return; + } + if (err.code === 'NO_VALID_SCHEMAS') { res.status(404).send(errorPage404Message(err.message)); return; diff --git a/graphql/server/src/middleware/database-access-policy.ts b/graphql/server/src/middleware/database-access-policy.ts index 75fa7e477d..b3decca6e9 100644 --- a/graphql/server/src/middleware/database-access-policy.ts +++ b/graphql/server/src/middleware/database-access-policy.ts @@ -3,7 +3,9 @@ import './types'; import { ConstructiveError } from '@constructive-io/errors'; import { Logger } from '@pgpmjs/logger'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; -import { getPgPool } from 'pg-cache'; +import type { PoolConfig, QueryResult, QueryResultRow } from 'pg'; +import { Pool } from 'pg'; +import { getPgEnvOptions } from 'pg-env'; import { ApiError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; @@ -16,6 +18,11 @@ const POLICY_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/; const MAX_POLICY_MESSAGE_LENGTH = 512; const POLICY_UNAVAILABLE_CODE = 'DATABASE_ACCESS_POLICY_UNAVAILABLE'; const POLICY_UNAVAILABLE_MESSAGE = 'Database access policy is temporarily unavailable.'; +const DEFAULT_POLICY_POOL_MAX = 2; +const DEFAULT_POLICY_TIMEOUT_MS = 1500; +const MAX_POLICY_POOL_MAX = 8; +const MAX_POLICY_TIMEOUT_MS = 30_000; +const MIN_POLICY_TIMEOUT_MS = 100; interface PolicyFunction { schema: string; @@ -38,6 +45,23 @@ interface DeniedDecision { type PolicyDecision = { allowed: true } | DeniedDecision; +interface PolicyPool { + query( + text: string, + values?: unknown[] + ): Promise>; + end(): Promise; + on?(event: 'error', listener: (error: Error & { code?: string }) => void): unknown; +} + +interface DatabaseAccessPolicyDependencies { + createPool?: (config: PoolConfig) => PolicyPool; +} + +export type DatabaseAccessPolicyMiddleware = RequestHandler & { + close: () => Promise; +}; + const parsePolicyFunction = (value: string): PolicyFunction | null => { const match = POLICY_FUNCTION_PATTERN.exec(value); if (!match) return null; @@ -137,6 +161,55 @@ const rejectUnavailable = ( 'internal' ); +const withClose = ( + middleware: RequestHandler, + pool?: PolicyPool +): DatabaseAccessPolicyMiddleware => { + let closePromise: Promise | null = null; + return Object.assign(middleware, { + close: (): Promise => { + if (!pool) return Promise.resolve(); + closePromise ??= pool.end(); + return closePromise; + } + }); +}; + +const validPoolMax = (value: number): boolean => + Number.isInteger(value) && value >= 1 && value <= MAX_POLICY_POOL_MAX; + +const validTimeout = (value: number): boolean => + Number.isInteger(value) && + value >= MIN_POLICY_TIMEOUT_MS && + value <= MAX_POLICY_TIMEOUT_MS; + +const createPolicyPool = ( + opts: ApiOptions, + poolMax: number, + timeoutMs: number, + dependencies: DatabaseAccessPolicyDependencies +): PolicyPool => { + const pg = getPgEnvOptions(opts.pg); + const config: PoolConfig = { + ...pg, + max: poolMax, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: timeoutMs, + statement_timeout: timeoutMs, + query_timeout: timeoutMs, + allowExitOnIdle: true, + application_name: 'constructive_database_access_policy' + }; + const pool = dependencies.createPool?.(config) ?? new Pool(config); + pool.on?.('error', (error: Error & { code?: string }) => { + log.error('[database-access-policy] idle policy connection failed', { + code: error.code, + error: error.message + }); + }); + return pool; +}; + /** * Check a resolved database against an optional control-plane access policy. * @@ -146,11 +219,12 @@ const rejectUnavailable = ( * route cannot cache an access decision. */ export const createDatabaseAccessPolicyMiddleware = ( - opts: ApiOptions -): RequestHandler => { + opts: ApiOptions, + dependencies: DatabaseAccessPolicyDependencies = {} +): DatabaseAccessPolicyMiddleware => { const configuredFunction = opts.api?.databaseAccessPolicyFunction?.trim(); if (!configuredFunction) { - return (_req, _res, next): void => next(); + return withClose((_req, _res, next): void => next()); } const fn = parsePolicyFunction(configuredFunction); @@ -158,13 +232,23 @@ export const createDatabaseAccessPolicyMiddleware = ( log.error( '[database-access-policy] API_DATABASE_ACCESS_POLICY_FUNCTION must be two lowercase identifiers separated by a dot' ); - return (req, res, next): void => rejectUnavailable(req, res, next); + return withClose((req, res, next): void => rejectUnavailable(req, res, next)); + } + + const poolMax = opts.api?.databaseAccessPolicyPoolMax ?? DEFAULT_POLICY_POOL_MAX; + const timeoutMs = opts.api?.databaseAccessPolicyTimeoutMs ?? DEFAULT_POLICY_TIMEOUT_MS; + if (!validPoolMax(poolMax) || !validTimeout(timeoutMs)) { + log.error( + '[database-access-policy] policy pool bounds are invalid', + { poolMax, timeoutMs } + ); + return withClose((req, res, next): void => rejectUnavailable(req, res, next)); } - const pool = getPgPool(opts.pg); + const pool = createPolicyPool(opts, poolMax, timeoutMs, dependencies); const query = policyQuery(fn); - return async (req, res, next): Promise => { + const middleware: RequestHandler = async (req, res, next): Promise => { if (!req.databaseId) { log.error('[database-access-policy] API resolution did not provide a database id'); rejectUnavailable(req, res, next); @@ -192,4 +276,5 @@ export const createDatabaseAccessPolicyMiddleware = ( rejectRequest(req, res, next, decision, 'public'); }; + return withClose(middleware, pool); }; diff --git a/graphql/server/src/middleware/error-handler.ts b/graphql/server/src/middleware/error-handler.ts index 012bc53bac..d952687b29 100644 --- a/graphql/server/src/middleware/error-handler.ts +++ b/graphql/server/src/middleware/error-handler.ts @@ -12,9 +12,16 @@ const log = new Logger('error-handler'); const isDevelopment = (): boolean => getNodeEnv() === 'development'; +const isJsonApiPath = (path: string): boolean => + path === '/fn' || + path.startsWith('/fn/') || + path === '/v1' || + path.startsWith('/v1/'); + const wantsJson = (req: Request): boolean => { const accept = req.get('Accept') || ''; - return accept.includes('application/json') + return isJsonApiPath(req.path) + || accept.includes('application/json') || accept.includes('application/graphql-response+json') || Boolean(req.is('json')); }; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 5bd33a8fda..29c09a444e 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -83,6 +83,7 @@ class Server { private closed = false; private httpServer: HttpServer | null = null; private debugSampler: DebugSamplerHandle | null = null; + private databaseAccessPolicyClose: (() => Promise) | null = null; constructor(opts: ConstructiveOptions) { this.opts = getEnvOptions(opts); @@ -93,6 +94,7 @@ class Server { const app = express(); const api = createApiMiddleware(effectiveOpts); const databaseAccessPolicy = createDatabaseAccessPolicyMiddleware(effectiveOpts); + this.databaseAccessPolicyClose = databaseAccessPolicy.close; const apiSettings = createApiSettingsMiddleware(effectiveOpts); const authenticate = createAuthenticateMiddleware(effectiveOpts); const requestLogger = createRequestLogger({ observabilityEnabled }); @@ -153,6 +155,12 @@ class Server { app.use(poweredBy('constructive')); app.use(cookieParser()); app.use(cors(fallbackOrigin)); + app.use(parseDomains() as RequestHandler); + app.use(requestIp.mw()); + app.use(requestIdMiddleware()); + app.use(requestLogger); + app.use(api); + app.use(databaseAccessPolicy); app.use('/graphql', graphqlUpload.graphqlUploadExpress({ maxFileSize: 10 * 1024 * 1024, // 10 MB maxFiles: 10 @@ -160,12 +168,6 @@ class Server { // Rewrite Content-Type after graphql-upload so grafserv accepts the request app.use('/graphql', multipartBridge); - app.use(parseDomains() as RequestHandler); - app.use(requestIp.mw()); - app.use(requestIdMiddleware()); - app.use(requestLogger); - app.use(api); - app.use(databaseAccessPolicy); app.use(apiSettings); app.use(authenticate); app.use(createContextMiddleware({ @@ -352,6 +354,8 @@ class Server { if (this.httpServer?.listening) { await new Promise((resolve) => this.httpServer!.close(() => resolve())); } + await this.databaseAccessPolicyClose?.(); + this.databaseAccessPolicyClose = null; await closeDebugDatabasePools(); if (closeCaches) { await Server.closeCaches({ closePools: true }); diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 3e21b977e2..1a53218faf 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -41,6 +41,10 @@ export interface ApiOptions { * resolved database may accept a new request. */ databaseAccessPolicyFunction?: string; + /** Maximum connections reserved for database access-policy checks. */ + databaseAccessPolicyPoolMax?: number; + /** Deadline in milliseconds for acquiring a policy connection and querying it. */ + databaseAccessPolicyTimeoutMs?: number; /** * Optional registry codes whose GraphQL execution errors should set the HTTP * response status. Unset preserves the standard HTTP 200 execution response. @@ -82,6 +86,8 @@ export const apiDefaults: ApiOptions = { anonRole: 'administrator', roleName: 'administrator', isPublic: true, + databaseAccessPolicyPoolMax: 2, + databaseAccessPolicyTimeoutMs: 1500, metaSchemas: [ 'routing_public', 'metaschema_public', diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index caa7c84597..9b1b5ca50d 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -300,6 +300,12 @@ export const registry = { http: 503, message: 'Database access could not be verified. Please try again.' }), + INVALID_DATABASE_IDENTITY: defineError({ + code: 'INVALID_DATABASE_IDENTITY', + class: 'public', + http: 400, + message: 'X-Database-Id must be a valid UUID when private routing headers are used.' + }), RATE_LIMITED: defineError({ code: 'RATE_LIMITED', class: 'public', From 3d7c82bac2dfeabc3fae0abc78b8cb4111c06d95 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 28 Aug 2026 19:51:08 +0700 Subject: [PATCH 06/10] fix(graphql): pin internal identity trust to server surface --- .../graphile-internal-identity.test.ts | 68 +++++++++++++++++++ graphql/server/src/middleware/graphile.ts | 39 +++++------ .../src/middleware/internal-identity.ts | 45 ++++++++++++ 3 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 graphql/server/src/middleware/__tests__/graphile-internal-identity.test.ts create mode 100644 graphql/server/src/middleware/internal-identity.ts diff --git a/graphql/server/src/middleware/__tests__/graphile-internal-identity.test.ts b/graphql/server/src/middleware/__tests__/graphile-internal-identity.test.ts new file mode 100644 index 0000000000..8b4572fbbd --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-internal-identity.test.ts @@ -0,0 +1,68 @@ +import type { Request } from 'express'; + +import { + buildInternalIdentityContext, + trustsInternalIdentityHeaders +} from '../internal-identity'; + +const HEADERS = { + 'x-actor-id': 'actor-123', + 'x-entity-id': 'entity-456', + 'x-organization-id': 'organization-789' +}; + +const createRequest = (routeIsPublic: boolean): Request => ({ + api: { + apiId: 'published-api', + dbname: 'tenant_db', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['app_public'], + databaseId: '11111111-1111-4111-8111-111111111111', + isPublic: routeIsPublic + }, + requestId: 'request-123', + get: jest.fn((name: string) => HEADERS[name.toLowerCase() as keyof typeof HEADERS]) +} as unknown as Request); + +const buildPgSettings = ( + serverIsPublic: boolean, + routeIsPublic: boolean +): Record => { + const req = createRequest(routeIsPublic); + const trusted = trustsInternalIdentityHeaders({ isPublic: serverIsPublic }); + const result = buildInternalIdentityContext( + req, + 'authenticated', + { 'jwt.claims.api_id': 'published-api' }, + trusted + ); + + return result?.pgSettings ?? { role: 'anonymous' }; +}; + +describe('graphile internal identity trust boundary', () => { + it('rejects internal identity headers on a public server even when route metadata is private', () => { + const pgSettings = buildPgSettings(true, false); + + expect(pgSettings.role).toBe('anonymous'); + expect(pgSettings).not.toHaveProperty('jwt.claims.user_id'); + expect(pgSettings).not.toHaveProperty('jwt.claims.principal_id'); + expect(pgSettings).not.toHaveProperty('jwt.claims.entity_id'); + expect(pgSettings).not.toHaveProperty('jwt.claims.organization_id'); + }); + + it('trusts internal identity headers on a private server for a published API', () => { + const pgSettings = buildPgSettings(false, true); + + expect(pgSettings).toMatchObject({ + role: 'authenticated', + 'jwt.claims.user_id': 'actor-123', + 'jwt.claims.principal_id': 'actor-123', + 'jwt.claims.entity_id': 'entity-456', + 'jwt.claims.organization_id': 'organization-789', + 'jwt.claims.api_id': 'published-api', + 'request.id': 'request-123' + }); + }); +}); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index d167dd3e18..ed41eaee25 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -22,6 +22,7 @@ import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import { createGraphQLErrorHttpStatusPlugin } from '../plugins/graphql-error-http-status-plugin'; import type { DatabaseSettings } from '../types'; +import { buildInternalIdentityContext, trustsInternalIdentityHeaders } from './internal-identity'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -166,6 +167,7 @@ const buildPreset = ( schemas: string[], anonRole: string, roleName: string, + trustInternalIdentityHeaders: boolean, databaseSettings?: DatabaseSettings, apiId?: string, compute?: ComputeConfig, @@ -278,33 +280,22 @@ const buildPreset = ( return { pgSettings }; } - // Private (in-cluster) surface: there is no token — identity + // Private (in-cluster) deployment surface: there is no token — identity // arrives on the trusted internal X-* headers stamped by the // dispatching worker/sync gateway (the same vocabulary as // X-Database-Id above). Map it into per-request claims so writes // made through this surface carry actor attribution. Never applied - // on the public surface, where client-supplied identity headers - // must not assert identity. - const headerActorId = req.get('X-Actor-Id'); - if (req.api?.isPublic === false && headerActorId) { - const pgSettings: Record = { - role: roleName, - 'jwt.claims.user_id': headerActorId, - 'jwt.claims.principal_id': headerActorId, - ...context - }; - const headerEntityId = req.get('X-Entity-Id'); - if (headerEntityId) { - pgSettings['jwt.claims.entity_id'] = headerEntityId; - } - const headerOrganizationId = req.get('X-Organization-Id'); - if (headerOrganizationId) { - pgSettings['jwt.claims.organization_id'] = headerOrganizationId; - } - if (req.requestId) { - pgSettings['request.id'] = req.requestId; - } - return { pgSettings }; + // on a public server, where client-supplied identity headers must + // not assert identity. Route/API publication metadata is deliberately + // excluded from this trust decision. + const internalIdentityContext = buildInternalIdentityContext( + req, + roleName, + context, + trustInternalIdentityHeaders + ); + if (internalIdentityContext) { + return internalIdentityContext; } } @@ -326,6 +317,7 @@ const buildPreset = ( export const graphile = (opts: ConstructiveOptions): RequestHandler => { const observabilityEnabled = isGraphqlObservabilityEnabled(opts.server?.host); + const allowInternalIdentityHeaders = trustsInternalIdentityHeaders(opts.api); return async (req: Request, res: Response, next: NextFunction) => { const label = reqLabel(req); @@ -413,6 +405,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { schema || [], anonRole, roleName, + allowInternalIdentityHeaders, api.databaseSettings, api.apiId, compute, diff --git a/graphql/server/src/middleware/internal-identity.ts b/graphql/server/src/middleware/internal-identity.ts new file mode 100644 index 0000000000..f10ce5222c --- /dev/null +++ b/graphql/server/src/middleware/internal-identity.ts @@ -0,0 +1,45 @@ +import type { Request } from 'express'; + +interface ServerApiTrustOptions { + isPublic?: boolean; +} + +/** + * Internal identity headers are trusted only on an explicitly private server. + * Route and API publication metadata must never be passed to this decision. + */ +export const trustsInternalIdentityHeaders = ( + serverApiOptions?: ServerApiTrustOptions +): boolean => serverApiOptions?.isPublic === false; + +export const buildInternalIdentityContext = ( + req: Request, + roleName: string, + context: Record, + trusted: boolean +): { pgSettings: Record } | null => { + const actorId = req.get('X-Actor-Id'); + if (!trusted || !actorId) { + return null; + } + + const pgSettings: Record = { + role: roleName, + 'jwt.claims.user_id': actorId, + 'jwt.claims.principal_id': actorId, + ...context + }; + const entityId = req.get('X-Entity-Id'); + if (entityId) { + pgSettings['jwt.claims.entity_id'] = entityId; + } + const organizationId = req.get('X-Organization-Id'); + if (organizationId) { + pgSettings['jwt.claims.organization_id'] = organizationId; + } + if (req.requestId) { + pgSettings['request.id'] = req.requestId; + } + + return { pgSettings }; +}; From 3a741faa4212b8db29e02f10019e3cf3820bb87e Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sun, 30 Aug 2026 12:26:08 +0700 Subject: [PATCH 07/10] fix(graphql): enforce access policy contract --- graphql/server/README.md | 2 +- .../database-access-policy-pipeline.test.ts | 6 +++ .../__tests__/database-access-policy.test.ts | 44 ++++++++++++++++--- .../src/middleware/database-access-policy.ts | 44 +++++++++---------- 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/graphql/server/README.md b/graphql/server/README.md index 06aefa312e..dbb6343f45 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -129,7 +129,7 @@ returns table ( ) ``` -An allowed row must set the three denial fields to `NULL`. A denied row must provide an uppercase machine code, a non-empty client-safe message of at most 512 characters, and an HTTP status from 400 through 599. GraphQL denials keep a GraphQL error envelope and use the returned HTTP status, which is also present in `errors[].extensions.http`; REST denials use the same status. +An allowed row must set the three denial fields to `NULL`. A denied row must provide a non-empty client-safe message of at most 512 characters and one exact code/status pair: `DATABASE_BILLING_SUSPENDED` with HTTP 402 for definitive non-payment, or `DATABASE_ACCESS_POLICY_UNAVAILABLE` with HTTP 503 when policy state is missing, malformed, or unavailable. Any other row fails closed as `DATABASE_ACCESS_POLICY_UNAVAILABLE` with HTTP 503. GraphQL denials keep a GraphQL error envelope and use the returned HTTP status, which is also present in `errors[].extensions.http`; REST denials use the same status. ## Configuration diff --git a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts index 33416d651a..48d0f66a5d 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -35,6 +35,7 @@ const mockCreateDefaultRegistry = createDefaultRegistry as jest.MockedFunction next()); const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; @@ -154,6 +155,7 @@ function pipelineApp(apiOptions = options()) { const app = express(); app.use(createApiMiddleware(apiOptions)); app.use(createDatabaseAccessPolicyMiddleware(apiOptions)); + app.use(jsonParser); app.use('/graphql', multipartParser); app.use(createApiSettingsMiddleware(apiOptions)); app.use((_req, res) => res.status(204).end()); @@ -192,6 +194,7 @@ describe('database access policy pipeline ordering', () => { }); expect(tenantQuery).not.toHaveBeenCalled(); expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(jsonParser).not.toHaveBeenCalled(); expect(multipartParser).not.toHaveBeenCalled(); }); @@ -204,6 +207,7 @@ describe('database access policy pipeline ordering', () => { .send({ query: '{ __typename }' }); expect(response.status).toBe(204); + expect(jsonParser).toHaveBeenCalledTimes(1); expect(multipartParser).toHaveBeenCalledTimes(1); expect(tenantQuery).toHaveBeenCalled(); expect(events.indexOf('policy')).toBeGreaterThan(events.indexOf('route-resolution')); @@ -231,6 +235,7 @@ describe('database access policy pipeline ordering', () => { expect(routingQuery).not.toHaveBeenCalled(); expect(tenantQuery).not.toHaveBeenCalled(); expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(jsonParser).not.toHaveBeenCalled(); expect(multipartParser).not.toHaveBeenCalled(); }); @@ -456,6 +461,7 @@ describe('database access policy pipeline ordering', () => { expect(routingQuery).not.toHaveBeenCalled(); expect(tenantQuery).not.toHaveBeenCalled(); expect(mockRegistryResolve).not.toHaveBeenCalled(); + expect(jsonParser).not.toHaveBeenCalled(); expect(multipartParser).not.toHaveBeenCalled(); }); }); diff --git a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts index 2895fc05f6..8ef0791a5f 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts @@ -65,6 +65,13 @@ const denyRow: TestPolicyRow = { http_status: 402 }; +const unavailableRow: TestPolicyRow = { + allowed: false, + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + message: 'Database access could not be verified. Please try again.', + http_status: 503 +}; + describe('database access policy middleware', () => { beforeEach(() => { jest.clearAllMocks(); @@ -181,6 +188,29 @@ describe('database access policy middleware', () => { expect(next).not.toHaveBeenCalled(); }); + it('returns the exact GraphQL 503 contract for unavailable policy state', async () => { + const query = jest.fn().mockResolvedValue({ rows: [unavailableRow] }); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, status, json } = createResponse(); + + await middleware(createRequest('/graphql'), res, jest.fn()); + + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith({ + errors: [{ + message: unavailableRow.message, + extensions: { + code: unavailableRow.code, + class: 'public', + http: 503 + } + }] + }); + }); + it.each([ '/fn/invocations/invocation-1', '/v1/threads/thread-1/messages' @@ -237,7 +267,7 @@ describe('database access policy middleware', () => { expect(response.body).toEqual({ error: { code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', - message: 'Database access policy is temporarily unavailable.', + message: 'Database access could not be verified. Please try again.', requestId: 'request-503' } }); @@ -255,10 +285,10 @@ describe('database access policy middleware', () => { expect(mockCreatePool).not.toHaveBeenCalled(); expect(status).toHaveBeenCalledWith(503); const error = json.mock.calls[0][0].errors[0]; - expect(error.message).toBe('Database access policy is temporarily unavailable.'); + expect(error.message).toBe('Database access could not be verified. Please try again.'); expect(error.extensions).toEqual({ code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', - class: 'internal', + class: 'public', http: 503 }); expect(next).not.toHaveBeenCalled(); @@ -270,8 +300,10 @@ describe('database access policy middleware', () => { ['a non-boolean decision', [{ ...allowRow, allowed: 'true' }]], ['denial fields on allow', [{ ...allowRow, code: 'UNEXPECTED' }]], ['an unsafe denial code', [{ ...denyRow, code: 'bad-code' }]], + ['an unsupported denial code', [{ ...denyRow, code: 'DATABASE_PAYMENT_REQUIRED' }]], ['an empty denial message', [{ ...denyRow, message: ' ' }]], - ['an out-of-range status', [{ ...denyRow, http_status: 200 }]] + ['a mismatched suspension status', [{ ...denyRow, http_status: 503 }]], + ['a mismatched unavailable status', [{ ...unavailableRow, http_status: 402 }]] ])('fails closed when the policy returns %s', async (_label, rows) => { const query = jest.fn().mockResolvedValue({ rows }); mockCreatePool.mockReturnValue({ query } as unknown as Pool); @@ -320,10 +352,10 @@ describe('database access policy middleware', () => { expect(status).toHaveBeenCalledWith(503); expect(json.mock.calls[0][0].errors[0]).toMatchObject({ - message: 'Database access policy is temporarily unavailable.', + message: 'Database access could not be verified. Please try again.', extensions: { code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', - class: 'internal', + class: 'public', http: 503 } }); diff --git a/graphql/server/src/middleware/database-access-policy.ts b/graphql/server/src/middleware/database-access-policy.ts index b3decca6e9..b7eca83382 100644 --- a/graphql/server/src/middleware/database-access-policy.ts +++ b/graphql/server/src/middleware/database-access-policy.ts @@ -1,6 +1,6 @@ import './types'; -import { ConstructiveError } from '@constructive-io/errors'; +import { ConstructiveError, errors } from '@constructive-io/errors'; import { Logger } from '@pgpmjs/logger'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { PoolConfig, QueryResult, QueryResultRow } from 'pg'; @@ -14,10 +14,11 @@ import type { ApiOptions } from '../types'; const log = new Logger('database-access-policy'); const POLICY_FUNCTION_PATTERN = /^([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)$/; -const POLICY_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/; const MAX_POLICY_MESSAGE_LENGTH = 512; -const POLICY_UNAVAILABLE_CODE = 'DATABASE_ACCESS_POLICY_UNAVAILABLE'; -const POLICY_UNAVAILABLE_MESSAGE = 'Database access policy is temporarily unavailable.'; +const POLICY_DENIAL_HTTP_STATUS = { + DATABASE_BILLING_SUSPENDED: 402, + DATABASE_ACCESS_POLICY_UNAVAILABLE: 503 +} as const; const DEFAULT_POLICY_POOL_MAX = 2; const DEFAULT_POLICY_TIMEOUT_MS = 1500; const MAX_POLICY_POOL_MAX = 8; @@ -90,7 +91,10 @@ const parseDecision = (rows: PolicyDecisionRow[]): PolicyDecision => { throw new Error('policy decision allowed must be a boolean'); } - if (typeof row.code !== 'string' || !POLICY_ERROR_CODE_PATTERN.test(row.code)) { + if ( + row.code !== 'DATABASE_BILLING_SUSPENDED' && + row.code !== 'DATABASE_ACCESS_POLICY_UNAVAILABLE' + ) { throw new Error('denied policy decision has an invalid code'); } @@ -102,11 +106,8 @@ const parseDecision = (rows: PolicyDecisionRow[]): PolicyDecision => { throw new Error('denied policy decision has an invalid message'); } - if ( - !Number.isInteger(row.http_status) || - (row.http_status as number) < 400 || - (row.http_status as number) > 599 - ) { + const expectedHttpStatus = POLICY_DENIAL_HTTP_STATUS[row.code]; + if (row.http_status !== expectedHttpStatus) { throw new Error('denied policy decision has an invalid HTTP status'); } @@ -114,7 +115,7 @@ const parseDecision = (rows: PolicyDecisionRow[]): PolicyDecision => { allowed: false, code: row.code, message, - httpStatus: row.http_status as number + httpStatus: expectedHttpStatus }; }; @@ -149,17 +150,16 @@ const rejectUnavailable = ( req: Request, res: Response, next: NextFunction -): void => rejectRequest( - req, - res, - next, - { - code: POLICY_UNAVAILABLE_CODE, - message: POLICY_UNAVAILABLE_MESSAGE, - httpStatus: 503 - }, - 'internal' -); +): void => { + const error = errors.DATABASE_ACCESS_POLICY_UNAVAILABLE(); + rejectRequest( + req, + res, + next, + { code: error.code, message: error.message, httpStatus: error.http }, + error.errorClass + ); +}; const withClose = ( middleware: RequestHandler, From 597f6f522052569c92a74a9ccf3e76e86c234b8c Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sun, 30 Aug 2026 12:57:25 +0700 Subject: [PATCH 08/10] fix(graphql): rebind warm scoped routes --- .../database-access-policy-pipeline.test.ts | 134 ++++++++++++++++-- graphql/server/src/middleware/api.ts | 28 ++++ 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts index 48d0f66a5d..d5c137b43e 100644 --- a/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -39,8 +39,10 @@ const jsonParser = jest.fn(express.json()); const multipartParser = jest.fn((_req: Request, _res: Response, next: NextFunction) => next()); const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; +const REBOUND_DATABASE_ID = '33333333-3333-4333-8333-333333333333'; const PLATFORM_DATABASE_ID = '22222222-2222-4222-8222-222222222222'; const TENANT_DATABASE = 'customer_database'; +const REBOUND_TENANT_DATABASE = 'rebound_customer_database'; interface PolicyRow { allowed: boolean; @@ -72,14 +74,18 @@ const options = (): ApiOptions => ({ } } as ApiOptions); -const matchedRoute = () => ({ - route_binding_id: 'route-1', +const matchedRoute = ( + databaseId = DATABASE_ID, + apiId = 'api-1', + dbname = TENANT_DATABASE +) => ({ + route_binding_id: `route-${apiId}`, target_module: 'api', - target_source_id: 'api-1', + target_source_id: apiId, resolved_config: { - api_id: 'api-1', - database_id: DATABASE_ID, - dbname: TENANT_DATABASE, + api_id: apiId, + database_id: databaseId, + dbname, role_name: 'authenticated', anon_role: 'anonymous', is_public: false, @@ -91,7 +97,8 @@ function setupPools( policyRows: PolicyRow[], schemaBindings: Record = { app_public: DATABASE_ID - } + }, + routeResults = [matchedRoute()] ) { const events: string[] = []; const tenantQuery = jest.fn(async () => { @@ -99,6 +106,7 @@ function setupPools( return { rows: [] as unknown[] }; }); const policyQueue = [...policyRows]; + const routeQueue = [...routeResults]; const routingQuery = jest.fn(async (sql: string, params: unknown[]) => { if (sql.includes('FROM metaschema_public.schema scoped_schema')) { events.push('schema-binding'); @@ -132,7 +140,9 @@ function setupPools( } if (sql.includes('resolve_route')) { events.push('route-resolution'); - return { rows: [matchedRoute()] }; + return { + rows: [routeQueue.shift() ?? routeResults[routeResults.length - 1]] + }; } if (sql.includes('platform_private"."database_access')) { events.push('policy'); @@ -145,7 +155,10 @@ function setupPools( const tenantPool = { query: tenantQuery } as unknown as Pool; activePolicyPool = routingPool; mockGetPgPool.mockImplementation((pgOptions) => ( - pgOptions?.database === TENANT_DATABASE ? tenantPool : routingPool + pgOptions?.database === TENANT_DATABASE || + pgOptions?.database === REBOUND_TENANT_DATABASE + ? tenantPool + : routingPool )); return { events, routingQuery, tenantQuery }; @@ -163,6 +176,24 @@ function pipelineApp(apiOptions = options()) { return app; } +function graphileIdentityPipelineApp( + handlers: Map, + apiOptions = options() +) { + const app = express(); + app.use(createApiMiddleware(apiOptions)); + app.use(createDatabaseAccessPolicyMiddleware(apiOptions)); + app.use(createApiSettingsMiddleware(apiOptions)); + // Graphile selects its cached handler from this exact request key. + app.use((req: Request, res: Response, next: NextFunction) => { + const handler = req.svc_key ? handlers.get(req.svc_key) : undefined; + if (!handler) return next(new Error('Missing Graphile identity')); + handler(req, res); + }); + app.use(errorHandler); + return app; +} + describe('database access policy pipeline ordering', () => { beforeEach(() => { svcCache.clear(); @@ -214,6 +245,66 @@ describe('database access policy pipeline ordering', () => { expect(events.indexOf('tenant')).toBeGreaterThan(events.indexOf('policy')); }); + it('reselects the Graphile identity after a warm scoped route is rebound to another database', async () => { + const reboundApiId = 'api-2'; + const { routingQuery } = setupPools( + [allowRow, allowRow], + { app_public: DATABASE_ID }, + [ + matchedRoute(), + matchedRoute(REBOUND_DATABASE_ID, reboundApiId, REBOUND_TENANT_DATABASE) + ] + ); + const hostKey = 'api.example.com'; + const firstKey = `${hostKey}:database:${DATABASE_ID}:api:api-1`; + const reboundKey = `${hostKey}:database:${REBOUND_DATABASE_ID}:api:${reboundApiId}`; + const firstGraphile = jest.fn((req: Request, res: Response) => { + res.status(200).json({ + servedDatabaseId: DATABASE_ID, + requestDatabaseId: req.api?.databaseId + }); + }); + const reboundGraphile = jest.fn((req: Request, res: Response) => { + res.status(200).json({ + servedDatabaseId: REBOUND_DATABASE_ID, + requestDatabaseId: req.api?.databaseId + }); + }); + const app = graphileIdentityPipelineApp(new Map([ + [firstKey, firstGraphile], + [reboundKey, reboundGraphile] + ])); + + const first = await request(app) + .get('/graphql') + .set('Host', hostKey); + const second = await request(app) + .get('/graphql') + .set('Host', hostKey); + + expect(first.status).toBe(200); + expect(first.body).toEqual({ + servedDatabaseId: DATABASE_ID, + requestDatabaseId: DATABASE_ID + }); + expect(second.status).toBe(200); + expect(second.body).toEqual({ + servedDatabaseId: REBOUND_DATABASE_ID, + requestDatabaseId: REBOUND_DATABASE_ID + }); + expect(firstGraphile).toHaveBeenCalledTimes(1); + expect(reboundGraphile).toHaveBeenCalledTimes(1); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('resolve_route') + )).toHaveLength(2); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('database_access') + ).map(([, params]) => params[0])).toEqual([ + DATABASE_ID, + REBOUND_DATABASE_ID + ]); + }); + it('rejects malformed private database identity before PostgreSQL or multipart parsing', async () => { const { routingQuery, tenantQuery } = setupPools([allowRow]); @@ -438,12 +529,35 @@ describe('database access policy pipeline ordering', () => { http: 402 }); expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes(identitySql))) - .toHaveLength(_label === 'X-Schemata' ? 2 : 1); + .toHaveLength(_label === 'X-Schemata' || _label === 'scoped route' ? 2 : 1); expect(routingQuery.mock.calls.filter(([sql]) => String(sql).includes('database_access'))).toHaveLength(2); expect(tenantQuery).toHaveBeenCalledTimes(tenantQueriesAfterAllow); expect(mockRegistryResolve).toHaveBeenCalledTimes(settingsAfterAllow); }); + it('keeps the warm scoped-route cache behavior when the optional access policy is unset', async () => { + const tenantOptions = options(); + delete tenantOptions.api?.databaseAccessPolicyFunction; + const { routingQuery } = setupPools([allowRow]); + const app = pipelineApp(tenantOptions); + + const first = await request(app) + .get('/graphql') + .set('Host', 'api.example.com'); + const second = await request(app) + .get('/graphql') + .set('Host', 'api.example.com'); + + expect(first.status).toBe(204); + expect(second.status).toBe(204); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('resolve_route') + )).toHaveLength(1); + expect(routingQuery.mock.calls.filter(([sql]) => + String(sql).includes('database_access') + )).toHaveLength(0); + }); + it('rejects private routing without database identity before PostgreSQL or multipart parsing', async () => { const { routingQuery, tenantQuery } = setupPools([allowRow]); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 3b941a4a9a..0775c29d1e 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -309,6 +309,15 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { return baseKey; }; +const getScopedRouteSvcKey = ( + hostKey: string, + structure: ApiStructure +): string => { + if (!structure.databaseId) return hostKey; + const databaseKey = `${hostKey}:database:${structure.databaseId}`; + return structure.apiId ? `${databaseKey}:api:${structure.apiId}` : databaseKey; +}; + const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedModuleSettings = {}): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', @@ -507,6 +516,25 @@ export const getApiIdentity = async ( let databaseSchemas: string[] | null = null; const liveAccessPolicyConfigured = !!opts.api?.databaseAccessPolicyFunction?.trim(); + // A hostname is mutable routing state. Resolve it on every policy-protected + // request, then select caches by the resolved database/API identity so a + // warm handler for the previous binding cannot serve a rebound route. + if (liveAccessPolicyConfigured && mode === 'scoped-route') { + const result = await resolveScopedRoute(ctx); + if (!result) return result; + + const resolvedCacheKey = getScopedRouteSvcKey(cacheKey, result); + req.svc_key = resolvedCacheKey; + const cached = svcCache.get(resolvedCacheKey) as ApiStructure | undefined; + if (cached) { + log.debug(`Cache HIT for live scoped-route key=${resolvedCacheKey}`); + return cached; + } + + if (result.databaseId) svcCache.set(resolvedCacheKey, result); + return result; + } + // X-Schemata creates an administrator API over caller-selected schemas. Its // database binding therefore remains a live routing-plane check, including // on cache hits, and runs before the billing policy or Graphile can use it. From 1192516c7ba892dd849dbbbefd6aa86988c271c4 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sun, 30 Aug 2026 13:01:35 +0700 Subject: [PATCH 09/10] fix(graphql): flush scoped route identities --- .../src/middleware/__tests__/flush.test.ts | 72 +++++++++++++++++++ graphql/server/src/middleware/api.ts | 14 +--- graphql/server/src/middleware/flush.ts | 26 ++++--- 3 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 graphql/server/src/middleware/__tests__/flush.test.ts diff --git a/graphql/server/src/middleware/__tests__/flush.test.ts b/graphql/server/src/middleware/__tests__/flush.test.ts new file mode 100644 index 0000000000..c6d54333d1 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/flush.test.ts @@ -0,0 +1,72 @@ +jest.mock('@pgpmjs/server-utils', () => ({ + svcCache: new Map() +})); + +jest.mock('graphile-cache', () => ({ + graphileCache: new Map() +})); + +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { svcCache } from '@pgpmjs/server-utils'; +import { graphileCache } from 'graphile-cache'; +import type { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; + +import { flushService } from '../flush'; + +const mockGetPgPool = getPgPool as jest.MockedFunction; +const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_DATABASE_ID = '22222222-2222-4222-8222-222222222222'; +const HOST = 'api.example.com'; + +const options = { + pg: { database: 'routing_database' }, + api: { isPublic: true } +} as ConstructiveOptions; + +describe('flushService', () => { + beforeEach(() => { + graphileCache.clear(); + svcCache.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + graphileCache.clear(); + svcCache.clear(); + }); + + it('evicts plain and database/API identity keys from both caches', async () => { + const databaseKey = `${HOST}:database:${DATABASE_ID}`; + const identityKey = `${databaseKey}:api:api-1`; + const otherIdentityKey = `${HOST}:database:${OTHER_DATABASE_ID}:api:api-2`; + for (const cache of [graphileCache, svcCache]) { + cache.set(HOST, {} as never); + cache.set(databaseKey, {} as never); + cache.set(identityKey, {} as never); + cache.set(otherIdentityKey, {} as never); + } + const query = jest.fn().mockResolvedValue({ + rowCount: 1, + rows: [{ hostname: HOST }] + }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + + await flushService(options, DATABASE_ID); + + expect(query).toHaveBeenCalledWith( + expect.stringContaining('FROM "routing_public".domains'), + [DATABASE_ID] + ); + for (const cache of [graphileCache, svcCache]) { + expect(cache.has(HOST)).toBe(false); + expect(cache.has(databaseKey)).toBe(false); + expect(cache.has(identityKey)).toBe(false); + expect(cache.has(otherIdentityKey)).toBe(true); + } + }); +}); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 0775c29d1e..ad0b64860a 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -517,21 +517,13 @@ export const getApiIdentity = async ( const liveAccessPolicyConfigured = !!opts.api?.databaseAccessPolicyFunction?.trim(); // A hostname is mutable routing state. Resolve it on every policy-protected - // request, then select caches by the resolved database/API identity so a - // warm handler for the previous binding cannot serve a rebound route. + // request, then select the downstream Graphile handler by the resolved + // database/API identity so a warm handler cannot serve a rebound route. if (liveAccessPolicyConfigured && mode === 'scoped-route') { const result = await resolveScopedRoute(ctx); if (!result) return result; - const resolvedCacheKey = getScopedRouteSvcKey(cacheKey, result); - req.svc_key = resolvedCacheKey; - const cached = svcCache.get(resolvedCacheKey) as ApiStructure | undefined; - if (cached) { - log.debug(`Cache HIT for live scoped-route key=${resolvedCacheKey}`); - return cached; - } - - if (result.databaseId) svcCache.set(resolvedCacheKey, result); + req.svc_key = getScopedRouteSvcKey(cacheKey, result); return result; } diff --git a/graphql/server/src/middleware/flush.ts b/graphql/server/src/middleware/flush.ts index 1ff9ee6d3f..f45cf09b13 100644 --- a/graphql/server/src/middleware/flush.ts +++ b/graphql/server/src/middleware/flush.ts @@ -11,6 +11,15 @@ import { getRoutingSchema, isValidSchemaName } from './routing'; const log = new Logger('flush'); +const evictMatchingCaches = (matches: (key: string) => boolean): void => { + for (const key of graphileCache.keys()) { + if (matches(key)) graphileCache.delete(key); + } + for (const key of svcCache.keys()) { + if (matches(key)) svcCache.delete(key); + } +}; + export const flush = async ( req: Request, res: Response, @@ -37,13 +46,8 @@ export const flushService = async ( const schemata = new RegExp(`^schemata:${databaseId}:.*`); const meta = new RegExp(`^metaschema:api:${databaseId}`); - if (!opts.api.isPublic) { - graphileCache.forEach((_, k: string) => { - if (api.test(k) || schemata.test(k) || meta.test(k)) { - graphileCache.delete(k); - svcCache.delete(k); - } - }); + if (opts.api?.isPublic === false) { + evictMatchingCaches((key) => api.test(key) || schemata.test(key) || meta.test(key)); } const routingSchema = getRoutingSchema(opts); @@ -63,8 +67,12 @@ export const flushService = async ( for (const row of svc.rows) { const key: string | undefined = row.hostname || undefined; if (key) { - graphileCache.delete(key); - svcCache.delete(key); + const databaseKey = `${key}:database:${databaseId}`; + evictMatchingCaches((candidate) => + candidate === key || + candidate === databaseKey || + candidate.startsWith(`${databaseKey}:api:`) + ); } } }; From b1b6cb232645bd55964e0bd80ec9ca29e6aca0bb Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sun, 30 Aug 2026 13:06:22 +0700 Subject: [PATCH 10/10] fix(graphql): flush wildcard route identities --- .../src/middleware/__tests__/flush.test.ts | 45 +++++++++++++++++++ graphql/server/src/middleware/flush.ts | 25 ++++++++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/graphql/server/src/middleware/__tests__/flush.test.ts b/graphql/server/src/middleware/__tests__/flush.test.ts index c6d54333d1..f3669e40d1 100644 --- a/graphql/server/src/middleware/__tests__/flush.test.ts +++ b/graphql/server/src/middleware/__tests__/flush.test.ts @@ -22,6 +22,8 @@ const mockGetPgPool = getPgPool as jest.MockedFunction; const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; const OTHER_DATABASE_ID = '22222222-2222-4222-8222-222222222222'; const HOST = 'api.example.com'; +const WILDCARD_HOST = '*.example.com'; +const WILDCARD_CHILD_HOST = 'customer.example.com'; const options = { pg: { database: 'routing_database' }, @@ -69,4 +71,47 @@ describe('flushService', () => { expect(cache.has(otherIdentityKey)).toBe(true); } }); + + it('evicts wildcard child identities by exact database segment only', async () => { + const childIdentityKey = + `${WILDCARD_CHILD_HOST}:database:${DATABASE_ID}:api:api-1`; + const siblingIdentityKey = + `other.example.com:database:${DATABASE_ID}`; + const otherDatabaseKey = + `${WILDCARD_CHILD_HOST}:database:${OTHER_DATABASE_ID}:api:api-2`; + const databasePrefixCollision = + `${WILDCARD_CHILD_HOST}:database:${DATABASE_ID}0:api:api-3`; + const apiIdCollision = + `${WILDCARD_CHILD_HOST}:database:${OTHER_DATABASE_ID}:api:api-4:database:${DATABASE_ID}`; + const privateKeyCollision = + `api:${OTHER_DATABASE_ID}:customer:database:${DATABASE_ID}`; + for (const cache of [graphileCache, svcCache]) { + cache.set(WILDCARD_HOST, {} as never); + cache.set(WILDCARD_CHILD_HOST, {} as never); + cache.set(childIdentityKey, {} as never); + cache.set(siblingIdentityKey, {} as never); + cache.set(otherDatabaseKey, {} as never); + cache.set(databasePrefixCollision, {} as never); + cache.set(apiIdCollision, {} as never); + cache.set(privateKeyCollision, {} as never); + } + const query = jest.fn().mockResolvedValue({ + rowCount: 1, + rows: [{ hostname: WILDCARD_HOST }] + }); + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + + await flushService(options, DATABASE_ID); + + for (const cache of [graphileCache, svcCache]) { + expect(cache.has(WILDCARD_HOST)).toBe(false); + expect(cache.has(WILDCARD_CHILD_HOST)).toBe(true); + expect(cache.has(childIdentityKey)).toBe(false); + expect(cache.has(siblingIdentityKey)).toBe(false); + expect(cache.has(otherDatabaseKey)).toBe(true); + expect(cache.has(databasePrefixCollision)).toBe(true); + expect(cache.has(apiIdCollision)).toBe(true); + expect(cache.has(privateKeyCollision)).toBe(true); + } + }); }); diff --git a/graphql/server/src/middleware/flush.ts b/graphql/server/src/middleware/flush.ts index f45cf09b13..4fead15a28 100644 --- a/graphql/server/src/middleware/flush.ts +++ b/graphql/server/src/middleware/flush.ts @@ -20,6 +20,19 @@ const evictMatchingCaches = (matches: (key: string) => boolean): void => { } }; +const isScopedDatabaseIdentityKey = (key: string, databaseId: string): boolean => { + const marker = ':database:'; + const markerIndex = key.indexOf(marker); + if (markerIndex <= 0) return false; + if (!/^[a-z0-9.-]+$/i.test(key.slice(0, markerIndex))) return false; + + const identity = key.slice(markerIndex + marker.length); + const apiIndex = identity.indexOf(':api:'); + if (apiIndex === -1) return identity === databaseId; + return identity.slice(0, apiIndex) === databaseId && + identity.length > apiIndex + ':api:'.length; +}; + export const flush = async ( req: Request, res: Response, @@ -50,6 +63,10 @@ export const flushService = async ( evictMatchingCaches((key) => api.test(key) || schemata.test(key) || meta.test(key)); } + // Scoped-route handlers are keyed by the concrete request host, which may + // be a child of a wildcard route. The database segment is the stable part. + evictMatchingCaches((key) => isScopedDatabaseIdentityKey(key, databaseId)); + const routingSchema = getRoutingSchema(opts); if (!isValidSchemaName(routingSchema)) { log.warn(`[flush] invalid routing schema name: ${routingSchema}`); @@ -67,12 +84,8 @@ export const flushService = async ( for (const row of svc.rows) { const key: string | undefined = row.hostname || undefined; if (key) { - const databaseKey = `${key}:database:${databaseId}`; - evictMatchingCaches((candidate) => - candidate === key || - candidate === databaseKey || - candidate.startsWith(`${databaseKey}:api:`) - ); + // Legacy policy-unset routes remain cached by their configured hostname. + evictMatchingCaches((candidate) => candidate === key); } } };