diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..6409e8f1a0 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -51,6 +51,9 @@ 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 @@ -74,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 fa7dd645e8..2587a4028e 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -138,6 +138,40 @@ 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 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: + ' 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 014924ef24..59a666f3d8 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -13,6 +13,10 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial FEATURES_POSTGIS, 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, API_META_SCHEMAS, @@ -38,6 +42,13 @@ 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 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()) + .filter(Boolean); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -61,6 +72,10 @@ 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()) }), ...(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..dbb6343f45 100644 --- a/graphql/server/README.md +++ b/graphql/server/README.md @@ -109,7 +109,27 @@ 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. 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 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: + +```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 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 @@ -127,6 +147,9 @@ 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_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/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..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 { getApiConfig, 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,15 +54,15 @@ 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 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 { @@ -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 getApiConfig(createPrivateOptions(), req); + 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 new file mode 100644 index 0000000000..d5c137b43e --- /dev/null +++ b/graphql/server/src/middleware/__tests__/database-access-policy-pipeline.test.ts @@ -0,0 +1,581 @@ +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, { 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 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 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; + 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 = ( + databaseId = DATABASE_ID, + apiId = 'api-1', + dbname = TENANT_DATABASE +) => ({ + route_binding_id: `route-${apiId}`, + target_module: 'api', + target_source_id: apiId, + resolved_config: { + api_id: apiId, + database_id: databaseId, + dbname, + role_name: 'authenticated', + anon_role: 'anonymous', + is_public: false, + schemas: ['app_public'] + } +}); + +function setupPools( + policyRows: PolicyRow[], + schemaBindings: Record = { + app_public: DATABASE_ID + }, + routeResults = [matchedRoute()] +) { + const events: string[] = []; + const tenantQuery = jest.fn(async () => { + events.push('tenant'); + 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'); + 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 { + 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: [routeQueue.shift() ?? routeResults[routeResults.length - 1]] + }; + } + 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; + activePolicyPool = routingPool; + mockGetPgPool.mockImplementation((pgOptions) => ( + pgOptions?.database === TENANT_DATABASE || + pgOptions?.database === REBOUND_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(jsonParser); + app.use('/graphql', multipartParser); + app.use(createApiSettingsMiddleware(apiOptions)); + app.use((_req, res) => res.status(204).end()); + app.use(errorHandler); + 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(); + 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(); + expect(jsonParser).not.toHaveBeenCalled(); + expect(multipartParser).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(jsonParser).toHaveBeenCalledTimes(1); + 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('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]); + + 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(jsonParser).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]); + + 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'], + [ + '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' }, + 'FROM metaschema_public.schema scoped_schema' + ], + [ + '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(_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]); + + const response = await request(pipelineApp()) + .post('/graphql') + .set('Host', 'admin.example.com') + .set('X-Schemata', 'app_public') + .send({ query: '{ __typename }' }); + + expect(response.status).toBe(400); + expect(response.body.errors[0].extensions).toMatchObject({ + code: 'INVALID_DATABASE_IDENTITY', + http: 400 + }); + 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 new file mode 100644 index 0000000000..8ef0791a5f --- /dev/null +++ b/graphql/server/src/middleware/__tests__/database-access-policy.test.ts @@ -0,0 +1,414 @@ +import { ConstructiveError } from '@constructive-io/errors'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import type { Pool } from 'pg'; +import request from 'supertest'; + +import { ApiError } from '../../errors/api-errors'; +import type { ApiOptions } from '../../types'; +import { createDatabaseAccessPolicyMiddleware as createDatabaseAccessPolicyMiddlewareImpl } from '../database-access-policy'; +import { errorHandler } from '../error-handler'; + +const mockCreatePool = jest.fn(); +const createDatabaseAccessPolicyMiddleware = (opts: ApiOptions) => + createDatabaseAccessPolicyMiddlewareImpl(opts, { createPool: mockCreatePool }); +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 +}; + +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(); + }); + + 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(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] }); + 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(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] + ); + 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] }); + mockCreatePool.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 the exact GraphQL 402 contract when access is denied', async () => { + const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); + mockCreatePool.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(402); + expect(json).toHaveBeenCalledWith({ + errors: [{ + message: denyRow.message, + extensions: { + code: denyRow.code, + class: 'public', + http: 402 + } + }] + }); + 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' + ])('uses the stable REST JSON envelope for %s without an Accept header', async (path) => { + const query = jest.fn().mockResolvedValue({ rows: [denyRow] }); + 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-1'; + next(); + }); + app.use(middleware); + app.use((_req, res) => res.status(204).end()); + app.use(errorHandler); + + 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, + message: denyRow.message, + requestId: 'request-1' + } + }); + }); + + 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 could not be verified. Please try again.', + 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') + ); + const { res, status, json } = createResponse(); + const next = jest.fn(); + + await middleware(createRequest('/graphql'), res, next); + + expect(mockCreatePool).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(503); + const error = json.mock.calls[0][0].errors[0]; + expect(error.message).toBe('Database access could not be verified. Please try again.'); + expect(error.extensions).toEqual({ + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + class: 'public', + 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 unsupported denial code', [{ ...denyRow, code: 'DATABASE_PAYMENT_REQUIRED' }]], + ['an empty denial message', [{ ...denyRow, message: ' ' }]], + ['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); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, status, 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(status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + }); + + it('fails closed when policy evaluation throws', 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 { 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(); + }); + + 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 could not be verified. Please try again.', + extensions: { + code: 'DATABASE_ACCESS_POLICY_UNAVAILABLE', + class: 'public', + http: 503 + } + }); + }); + + it('fails closed when API resolution did not supply a database id', async () => { + const query = jest.fn(); + mockCreatePool.mockReturnValue({ query } as unknown as Pool); + const middleware = createDatabaseAccessPolicyMiddleware( + options('platform_private.database_access') + ); + const { res, status, 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(status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + }); + + it('evaluates the policy again for every request', async () => { + const query = jest.fn().mockResolvedValue({ rows: [allowRow] }); + mockCreatePool.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] }); + mockCreatePool.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/__tests__/flush.test.ts b/graphql/server/src/middleware/__tests__/flush.test.ts new file mode 100644 index 0000000000..f3669e40d1 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/flush.test.ts @@ -0,0 +1,117 @@ +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 WILDCARD_HOST = '*.example.com'; +const WILDCARD_CHILD_HOST = 'customer.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); + } + }); + + 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/__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/api.ts b/graphql/server/src/middleware/api.ts index 24f28e9228..ad0b64860a 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'; @@ -160,6 +163,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 // ============================================================================= @@ -185,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'; @@ -192,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}`; @@ -233,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 || '', @@ -276,6 +361,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, @@ -331,10 +449,8 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise => { + const headers = getRoutingHeaders(req); + validatePrivateDatabaseIdentity(opts, headers); const pool = getPgPool(opts.pg); const { domain, subdomains } = getUrlDomains(req); const subdomain = getSubdomain(subdomains); @@ -402,33 +500,72 @@ export const getApiConfig = 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, domain, subdomain, cacheKey, - headers: getRoutingHeaders(req), + headers, 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(); + + // A hostname is mutable routing state. Resolve it on every policy-protected + // 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; + + req.svc_key = getScopedRouteSvcKey(cacheKey, 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. + // 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 || []; @@ -439,7 +576,6 @@ export const getApiConfig = async ( } // Route to appropriate resolver based on mode - const mode = determineMode(ctx); let result: ApiConfigResult; switch (mode) { @@ -460,25 +596,72 @@ 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 and hydrate an API for callers that use this helper directly. + * + * 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); + return hydrateApiStructure(getPgPool(opts.pg), opts, result); + } + return result; +}; + // ============================================================================= // 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}`); 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,11 +675,19 @@ 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) { 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; @@ -518,3 +709,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 new file mode 100644 index 0000000000..b7eca83382 --- /dev/null +++ b/graphql/server/src/middleware/database-access-policy.ts @@ -0,0 +1,280 @@ +import './types'; + +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'; +import { Pool } from 'pg'; +import { getPgEnvOptions } from 'pg-env'; + +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 MAX_POLICY_MESSAGE_LENGTH = 512; +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; +const MAX_POLICY_TIMEOUT_MS = 30_000; +const MIN_POLICY_TIMEOUT_MS = 100; + +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; + +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; + 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 ( + row.code !== 'DATABASE_BILLING_SUSPENDED' && + row.code !== 'DATABASE_ACCESS_POLICY_UNAVAILABLE' + ) { + 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'); + } + + const expectedHttpStatus = POLICY_DENIAL_HTTP_STATUS[row.code]; + if (row.http_status !== expectedHttpStatus) { + throw new Error('denied policy decision has an invalid HTTP status'); + } + + return { + allowed: false, + code: row.code, + message, + httpStatus: expectedHttpStatus + }; +}; + +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 + }), + decision.httpStatus + ); + return; + } + + next(new ApiError(decision.code, decision.httpStatus, decision.message)); +}; + +const rejectUnavailable = ( + req: Request, + res: Response, + next: NextFunction +): 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, + 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. + * + * 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, + dependencies: DatabaseAccessPolicyDependencies = {} +): DatabaseAccessPolicyMiddleware => { + const configuredFunction = opts.api?.databaseAccessPolicyFunction?.trim(); + if (!configuredFunction) { + return withClose((_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 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 = createPolicyPool(opts, poolMax, timeoutMs, dependencies); + const query = policyQuery(fn); + + 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); + 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'); + }; + 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/middleware/flush.ts b/graphql/server/src/middleware/flush.ts index 1ff9ee6d3f..4fead15a28 100644 --- a/graphql/server/src/middleware/flush.ts +++ b/graphql/server/src/middleware/flush.ts @@ -11,6 +11,28 @@ 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); + } +}; + +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, @@ -37,15 +59,14 @@ 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)); } + // 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}`); @@ -63,8 +84,8 @@ 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); + // Legacy policy-unset routes remain cached by their configured hostname. + evictMatchingCaches((candidate) => candidate === key); } } }; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..ed41eaee25 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -20,7 +20,9 @@ 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 { buildInternalIdentityContext, trustsInternalIdentityHeaders } from './internal-identity'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -165,14 +167,19 @@ const buildPreset = ( schemas: string[], anonRole: string, roleName: string, + trustInternalIdentityHeaders: boolean, 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 @@ -273,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; } } @@ -321,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); @@ -403,7 +400,17 @@ 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, + allowInternalIdentityHeaders, + api.databaseSettings, + api.apiId, + compute, + opts.api?.graphqlErrorHttpStatusCodes + ); const creationPromise = observeGraphileBuild( { cacheKey: key, 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 }; +}; 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/server/src/server.ts b/graphql/server/src/server.ts index ea7f1e94c8..29c09a444e 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -25,12 +25,13 @@ 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'; 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'; @@ -82,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); @@ -91,6 +93,9 @@ 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 }); @@ -150,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 @@ -157,11 +168,7 @@ 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(apiSettings); app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, @@ -347,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 72fff4c739..1a53218faf 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -36,6 +36,20 @@ 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; + /** 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. + */ + graphqlErrorHttpStatusCodes?: string[]; /** Schemas containing metadata tables */ metaSchemas?: string[]; /** @@ -72,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/__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..9b1b5ca50d 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -288,6 +288,24 @@ 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.' + }), + 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',