Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions graphql/env/src/__tests__/runtime-pg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { getGraphQLEnvVars } from '../env';
import { getEnvOptions } from '../merge';

describe('GraphQL runtime PostgreSQL environment', () => {
it('maps dedicated runtime credentials without changing control-plane pg', () => {
const result = getGraphQLEnvVars({
GRAPHQL_RUNTIME_PGUSER: 'graphql_runtime',
GRAPHQL_RUNTIME_PGPASSWORD: 'runtime-secret',
});

expect(result.runtimePg).toEqual({
user: 'graphql_runtime',
password: 'runtime-secret',
});
expect(result.pg).toBeUndefined();
});

it('does not create a runtime override when both variables are absent', () => {
expect(getGraphQLEnvVars({}).runtimePg).toBeUndefined();
});

it('merges static runtime config, env password, and exact route identity', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-runtime-pg-'));
const identity = {
databaseId: 'database-a',
databaseName: 'tenant_a',
apiId: 'api-a',
schemas: ['tenant_a_public'],
roles: ['anonymous', 'authenticated'],
};
fs.writeFileSync(
path.join(cwd, 'pgpm.json'),
JSON.stringify({
runtimePg: {
host: 'runtime.internal',
database: 'tenant_a',
user: 'tenant_runtime',
password: 'config-secret',
},
runtimePgStaticIdentity: identity,
})
);

try {
const result = getEnvOptions({}, cwd, {
GRAPHQL_RUNTIME_PGPASSWORD: 'env-secret',
});
expect(result.runtimePg).toMatchObject({
host: 'runtime.internal',
database: 'tenant_a',
user: 'tenant_runtime',
password: 'env-secret',
});
expect(result.runtimePgStaticIdentity).toEqual(identity);
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
9 changes: 9 additions & 0 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
const {
GRAPHILE_SCHEMA,

GRAPHQL_RUNTIME_PGUSER,
GRAPHQL_RUNTIME_PGPASSWORD,

FEATURES_SIMPLE_INFLECTION,
FEATURES_OPPOSITE_BASE_NAMES,
FEATURES_POSTGIS,
Expand Down Expand Up @@ -47,6 +50,12 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
);

return {
...((GRAPHQL_RUNTIME_PGUSER || GRAPHQL_RUNTIME_PGPASSWORD) && {
runtimePg: {
...(GRAPHQL_RUNTIME_PGUSER && { user: GRAPHQL_RUNTIME_PGUSER }),
...(GRAPHQL_RUNTIME_PGPASSWORD && { password: GRAPHQL_RUNTIME_PGPASSWORD })
}
}),
graphile: {
...(GRAPHILE_SCHEMA && {
schema: GRAPHILE_SCHEMA.includes(',')
Expand Down
4 changes: 4 additions & 0 deletions graphql/env/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export const getEnvOptions = (
...(configOptions.graphile && { graphile: configOptions.graphile }),
...(configOptions.features && { features: configOptions.features }),
...(configOptions.api && { api: configOptions.api }),
...(configOptions.runtimePg && { runtimePg: configOptions.runtimePg }),
...(configOptions.runtimePgStaticIdentity && {
runtimePgStaticIdentity: configOptions.runtimePgStaticIdentity
}),
...(configOptions.sms && { sms: configOptions.sms }),
},
graphqlEnvOptions,
Expand Down
26 changes: 25 additions & 1 deletion graphql/types/src/constructive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
PgTestConnectionOptions,
ServerOptions} from '@pgpmjs/types';
import deepmerge from 'deepmerge';
import { PgConfig } from 'pg-env';
import type { PgConfig, PgPoolConfig } from 'pg-env';

import {
apiDefaults,
Expand All @@ -19,6 +19,24 @@ import {
import { LlmOptions } from './llm';
import { SmsOptions } from './sms';

/** Credential-free route facts used to resolve one runtime login. */
export interface RuntimePgResolverInput {
databaseId: string;
databaseName: string;
apiId: string;
/** Physical schemas in Graphile exposure order. */
schemas: readonly string[];
/** Request roles in `[anonymous, authenticated]` order. */
roles: readonly [anonymous: string, authenticated: string];
}

export type RuntimePgConfig = Partial<PgConfig> & { pool?: PgPoolConfig };

/** Resolve a least-privilege login from credential-free exact route facts. */
export type RuntimePgResolver = (
input: Readonly<RuntimePgResolverInput>
) => RuntimePgConfig | Promise<RuntimePgConfig>;

/**
* GraphQL-specific options for Constructive
*/
Expand All @@ -40,6 +58,12 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
db?: Partial<PgTestConnectionOptions>;
/** PostgreSQL connection configuration */
pg?: Partial<PgConfig>;
/** Static least-privilege tenant execution login. */
runtimePg?: RuntimePgConfig;
/** Exact route authorized to use the static runtime login. */
runtimePgStaticIdentity?: RuntimePgResolverInput;
/** Per-route least-privilege tenant execution login resolver. */
runtimePgResolver?: RuntimePgResolver;
/** PostGraphile/Graphile configuration */
graphile?: GraphileOptions;
/** HTTP server configuration */
Expand Down
5 changes: 4 additions & 1 deletion graphql/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export {
constructiveDefaults,
constructiveGraphqlDefaults,
ConstructiveGraphQLOptions,
ConstructiveOptions} from './constructive';
ConstructiveOptions,
RuntimePgConfig,
RuntimePgResolver,
RuntimePgResolverInput} from './constructive';

// Export GraphQL adapter types
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildContext } from '../src/context';

jest.mock('pg-cache', () => ({
getPgPool: jest.fn(() => ({ query: jest.fn(), connect: jest.fn() })),
getPgPoolIdentity: jest.fn(() => 'pg:v1:test'),
}));

describe('buildContext pgSettings forwarding', () => {
Expand Down
Loading