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
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import path from 'node:path';

import { makeSchema } from 'graphile-build';
import { makePgService, MinimalPreset } from 'graphile-settings';
import {
lexicographicSortSchema,
parse,
printSchema,
type ExecutionResult
} from 'graphql';
import type { Pool } from 'pg';
import { getConnections, PgTestClient } from 'pgsql-test';

const SCHEMA = 'scoped_equivalence';

// graphile-schema consumes these through graphile-settings in production; the
// test resolves that package's exact dependency instances without adding
// test-only runtime dependencies to graphile-schema.
const graphileSettingsDirectory = path.dirname(require.resolve('graphile-settings'));
const { execute } = require(require.resolve('grafast', {
paths: [graphileSettingsDirectory]
}));
const { withPgClientFromPgService } = require(require.resolve('graphile-build-pg', {
paths: [graphileSettingsDirectory]
}));

let pg: PgTestClient;
let pool: Pool;
let teardown: () => Promise<void>;

beforeAll(async () => {
const connections = await getConnections({}, []);
({ pg, teardown } = connections);
pool = connections.manager.getPool(pg.config);

await pg.query(`
CREATE SCHEMA ${SCHEMA};
CREATE TYPE ${SCHEMA}.item_state AS ENUM ('draft', 'published');

CREATE TABLE ${SCHEMA}.organizations (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);

CREATE TABLE ${SCHEMA}.items (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
organization_id bigint NOT NULL REFERENCES ${SCHEMA}.organizations(id),
state ${SCHEMA}.item_state NOT NULL DEFAULT 'draft',
title text NOT NULL
);

CREATE FUNCTION ${SCHEMA}.item_title(item ${SCHEMA}.items)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS 'SELECT item.title';

CREATE FUNCTION ${SCHEMA}.tenant_token()
RETURNS text
LANGUAGE sql
STABLE
AS 'SELECT ''scoped-equivalence-token''::text';

COMMENT ON TABLE ${SCHEMA}.items IS 'Scoped-introspection equivalence canary';
`);
});

beforeEach(async () => {
await pg.beforeEach();
});

afterEach(async () => {
await pg.afterEach();
});

afterAll(async () => {
await teardown();
});

async function build(
mode: 'stock' | 'scoped-required',
schemas = [SCHEMA],
scopedCatalogTypes?: 'all' | 'dependency-closure'
) {
const pgService = makePgService({
pool,
schemas,
introspectionMode: mode,
...(scopedCatalogTypes === undefined
? {}
: { introspectionScopedCatalogTypes: scopedCatalogTypes })
});
const built = await makeSchema({
extends: [MinimalPreset],
pgServices: [pgService]
});
return { ...built, pgService };
}

describe('scoped introspection schema equivalence', () => {
it('builds byte-equivalent SDL and executes the same token in every arm', async () => {
const stockBuild = await build('stock');
const scopedAllBuild = await build('scoped-required');
const scopedClosureBuild = await build(
'scoped-required',
[SCHEMA],
'dependency-closure'
);
const stock = printSchema(lexicographicSortSchema(stockBuild.schema));
const scopedAll = printSchema(lexicographicSortSchema(scopedAllBuild.schema));
const scopedClosure = printSchema(lexicographicSortSchema(
scopedClosureBuild.schema
));

expect(scopedAll).toBe(stock);
expect(scopedClosure).toBe(stock);
expect(scopedClosure).toContain('type Item');
expect(scopedClosure).toContain('enum ItemState');

for (const built of [stockBuild, scopedAllBuild, scopedClosureBuild]) {
const withPgClientKey = built.pgService.withPgClientKey ?? 'withPgClient';
const result = await execute({
schema: built.schema,
document: parse('{ tenantToken }'),
contextValue: {
pgSettings: {},
[withPgClientKey]: withPgClientFromPgService.bind(
null,
built.pgService
)
},
resolvedPreset: built.resolvedPreset
}) as ExecutionResult<{ tenantToken?: unknown }>;
if (Symbol.asyncIterator in result) {
throw new Error('tenant token canary unexpectedly returned a stream');
}
expect(result.errors).toBeUndefined();
expect(result.data).toEqual({ tenantToken: 'scoped-equivalence-token' });
}
});

it.each([undefined, 'dependency-closure'] as const)(
'fails closed when a required schema is absent (catalog types: %s)',
async (scopedCatalogTypes) => {
await expect(build(
'scoped-required',
['missing_required_schema'],
scopedCatalogTypes
))
.rejects.toThrow(
'did not find required schema(s): missing_required_schema'
);
}
);
});
10 changes: 10 additions & 0 deletions graphile/graphile-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const preset = {
makePgService({
connectionString: 'postgres://user:pass@localhost/mydb',
schemas: ['app_public'],
// Optional density optimization: retire the exact connection that ran
// catalog introspection instead of returning its enlarged backend to
// the request pool. The default is 'reuse'.
introspectionClientReleaseMode: 'destroy',
}),
],
};
Expand All @@ -52,6 +56,12 @@ serv.addTo(app, httpServer);
httpServer.listen(5000);
```

`introspectionClientReleaseMode: 'destroy'` applies only to the connection
checked out by Graphile for the catalog gather query. It fails closed when the
configured adaptor cannot prove that it owns and can destroy that exact
connection; caller-owned clients are never destroyed. Runtime requests still
use a dedicated tenant/API service and its normal pool.

## Features

The `ConstructivePreset` combines multiple plugins and configurations to provide a clean, opinionated GraphQL API. Below is a detailed breakdown of each feature.
Expand Down
61 changes: 60 additions & 1 deletion graphile/graphile-settings/__tests__/PublicKeySignature.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { PublicKeyChallengeConfig } from '../src/plugins/PublicKeySignature';
import { PublicKeySignature } from '../src/plugins/PublicKeySignature';
import {
PublicKeySignature,
withAnonymousPublicKeyClient,
} from '../src/plugins/PublicKeySignature';

const defaultConfig: PublicKeyChallengeConfig = {
schema: 'app_private',
anonymousRole: 'api_anonymous',
crypto_network: 'btc',
sign_up_with_key: 'sign_up_with_key',
sign_in_request_challenge: 'sign_in_request_challenge',
Expand All @@ -29,6 +33,7 @@ describe('PublicKeySignature plugin factory', () => {
it('accepts custom config values', () => {
const customConfig: PublicKeyChallengeConfig = {
schema: 'custom_schema',
anonymousRole: 'custom_anonymous',
crypto_network: 'eth',
sign_up_with_key: 'custom_signup',
sign_in_request_challenge: 'custom_challenge',
Expand Down Expand Up @@ -59,6 +64,11 @@ describe('PublicKeySignature config validation', () => {
expect(() => PublicKeySignature({ ...defaultConfig, schema: 'DROP TABLE' })).toThrow(/invalid schema/);
});

it('throws on an invalid anonymous role', () => {
expect(() => PublicKeySignature({ ...defaultConfig, anonymousRole: 'tenant-a; SET ROLE owner' }))
.toThrow(/invalid anonymousRole/);
});

it('throws on invalid function name', () => {
expect(() => PublicKeySignature({ ...defaultConfig, sign_up_with_key: 'evil"; DROP' })).toThrow(
/invalid sign_up_with_key/,
Expand Down Expand Up @@ -87,3 +97,52 @@ describe('PublicKeySignature config validation', () => {
expect(() => PublicKeySignature(defaultConfig)).not.toThrow();
});
});

describe('PublicKeySignature request context', () => {
it('preserves the complete request GUC contract while forcing the anonymous role', async () => {
const pgSettings = {
role: 'authenticated',
'jwt.claims.api_id': 'api-a',
'jwt.claims.database_id': 'database-a',
'jwt.claims.user_id': '',
'jwt.claims.session_id': '',
'request.id': 'request-a',
transaction_read_only: 'off',
search_path: 'pg_catalog, "tenant_a"',
row_security: 'on',
};
const pgClient = {
query: jest.fn(async (): Promise<{ rows: Record<string, unknown>[] }> => ({ rows: [] })),
};
const callback = jest.fn(async (client) => client);
const withPgClient = jest.fn(async (settings, fn) => {
expect(settings).toEqual({ ...pgSettings, role: 'api_anonymous' });
expect(settings).not.toBe(pgSettings);
return fn(pgClient);
});

await expect(withAnonymousPublicKeyClient(
withPgClient,
pgSettings,
'api_anonymous',
callback,
)).resolves.toBe(pgClient);

expect(withPgClient).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(pgClient);
expect(pgSettings.role).toBe('authenticated');
});

it.each([
['missing withPgClient', undefined, { role: 'anonymous' }, 'PG_CLIENT_CONTEXT_UNAVAILABLE'],
['missing pgSettings', jest.fn(), undefined, 'PG_SETTINGS_UNAVAILABLE'],
['null pgSettings', jest.fn(), null, 'PG_SETTINGS_UNAVAILABLE'],
])('fails closed for %s', async (_label, withPgClient, pgSettings, expected) => {
await expect(withAnonymousPublicKeyClient(
withPgClient as any,
pgSettings,
'api_anonymous',
async (): Promise<null> => null,
)).rejects.toThrow(expected);
});
});
Loading