Skip to content
Draft
5 changes: 5 additions & 0 deletions graphql/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
}
Expand Down
2 changes: 2 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 15 additions & 0 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ||
Expand All @@ -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()) }),
Expand Down
25 changes: 24 additions & 1 deletion graphql/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
Expand Down
10 changes: 6 additions & 4 deletions graphql/server/src/errors/graphql-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }],
});
}
8 changes: 7 additions & 1 deletion graphql/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
41 changes: 29 additions & 12 deletions graphql/server/src/middleware/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getPgPool>;
const DATABASE_ID = '11111111-1111-4111-8111-111111111111';

const createRequest = (headers: Record<string, string>): Request => {
const normalized = new Map(
Expand Down Expand Up @@ -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 {
Expand All @@ -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',
Expand All @@ -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();
});
});
Loading