Skip to content
Merged
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
Expand Up @@ -4,11 +4,11 @@
SET session_replication_role TO replica;

INSERT INTO metaschema_modules_public.events_module
(id, database_id, scope, schema_id, private_schema_id, public_schema_name, private_schema_name, events_table_name, record_event)
(id, database_id, scope, schema_id, private_schema_id, public_schema_name, private_schema_name, events_table_name, record_event, record_error)
VALUES
('6dba0004-0000-4000-8000-000000000001', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'app',
'6dba0001-0000-4000-8000-000000000002', '6dba0001-0000-4000-8000-000000000003',
'simple-pets-events-public', 'simple-pets-events-private', 'app_events', 'record_event')
'simple-pets-events-public', 'simple-pets-events-private', 'app_events', 'record_event', 'record_error')
ON CONFLICT (id) DO NOTHING;

SET session_replication_role TO DEFAULT;
29 changes: 27 additions & 2 deletions graphql/server-test/__fixtures__/seed/error-events/schema.sql
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
-- Error-events fixture: a stand-in auth module, a mutation that is refused with
-- a structured registry code (PRINCIPAL_CHILD_WIDENS, in the errors.raise_error
-- shape: MESSAGE = code, DETAIL = {code, context, class}), and a stand-in
-- events module with the tenant `record_event` the server resolves through
-- metaschema_modules_public.events_module.
-- events module with the tenant `record_event`/`record_error` the server
-- resolves through metaschema_modules_public.events_module.
--
-- Compose after app-schemas/simple-pets/schema.sql and scoped/test-data.sql.

Expand Down Expand Up @@ -75,6 +75,16 @@ CREATE TABLE "simple-pets-events-public".app_events (
);
GRANT SELECT, INSERT ON "simple-pets-events-public".app_events TO administrator, authenticated;

-- Event classification: an unregistered name defaults to feeds_levels = true,
-- so `record_error` registers every code it records as an error that earns no
-- ladder progress.
CREATE TABLE "simple-pets-events-public".event_types (
name text PRIMARY KEY,
category text NOT NULL,
feeds_levels boolean NOT NULL DEFAULT true
);
GRANT SELECT, INSERT ON "simple-pets-events-public".event_types TO administrator, authenticated;

CREATE FUNCTION "simple-pets-events-private".record_event(
step text,
actor_id uuid DEFAULT NULL,
Expand All @@ -85,6 +95,21 @@ CREATE FUNCTION "simple-pets-events-private".record_event(
$$ LANGUAGE sql VOLATILE;
GRANT EXECUTE ON FUNCTION "simple-pets-events-private".record_event(text, uuid, jsonb) TO administrator, authenticated;

CREATE FUNCTION "simple-pets-events-private".record_error(
code text,
actor_id uuid DEFAULT NULL,
payload jsonb DEFAULT NULL
) RETURNS void AS $$
WITH registered AS (
INSERT INTO "simple-pets-events-public".event_types (name, category, feeds_levels)
VALUES (code, 'error', false)
ON CONFLICT (name) DO NOTHING
)
INSERT INTO "simple-pets-events-public".app_events (name, actor_id, count, payload)
VALUES (code, actor_id, 1, payload);
$$ LANGUAGE sql VOLATILE;
GRANT EXECUTE ON FUNCTION "simple-pets-events-private".record_error(text, uuid, jsonb) TO administrator, authenticated;

-- ─── Metaschema registration ─────────────────────────────────────────────────

SET session_replication_role TO replica;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ afterAll(async () => {
await teardown();
});

describe('graphql.error (endpoint without an events module)', () => {
describe('refusal events (endpoint without an events module)', () => {
it('returns the refusal without error and records nothing', async () => {
const res = await refuse(request, 'principal-token');

Expand Down
27 changes: 18 additions & 9 deletions graphql/server-test/__tests__/error-events.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
* Error-events integration tests
*
* When an authenticated mutation is refused with a structured registry code,
* the server records `graphql.error` for the actor (principal, else user)
* through the tenant's events module `record_event` (resolved from
* the server records an event named after that code for the actor (principal,
* else user) through the tenant's events module `record_error` (resolved from
* metaschema_modules_public.events_module), in a fresh transaction after the
* failed mutation. The client response is unchanged; the database decides what
* each code means (e.g. PRINCIPAL_CHILD_WIDENS demotes a principal).
* each code means (e.g. PRINCIPAL_CHILD_WIDENS demotes a principal) and
* classifies it as an error that earns no ladder progress.
*
* The no-events-module case lives in error-events-no-module.integration so
* each suite has its own process-wide module-loader cache.
Expand All @@ -18,7 +19,7 @@
import type { PgTestClient } from 'pgsql-test/test-client';
import type supertest from 'supertest';

import { connect, events, HUMAN_ID, PRINCIPAL_ID, refuse } from './error-events.shared';
import { connect, events, eventTypes, HUMAN_ID, PRINCIPAL_ID, refuse } from './error-events.shared';

jest.setTimeout(30000);

Expand All @@ -34,7 +35,7 @@ afterAll(async () => {
await teardown();
});

describe('graphql.error (endpoint with an events module)', () => {
describe('refusal events (endpoint with an events module)', () => {
it('records the refusal for a principal, with the principal as actor', async () => {
const res = await refuse(request, 'principal-token');

Expand All @@ -46,12 +47,16 @@ describe('graphql.error (endpoint with an events module)', () => {
const { rows } = await events(pg);
expect(rows).toEqual([
{
name: 'graphql.error',
name: 'PRINCIPAL_CHILD_WIDENS',
actor_id: PRINCIPAL_ID,
payload: { code: 'PRINCIPAL_CHILD_WIDENS', operation: 'WidenChild' },
payload: { operation: 'WidenChild' },
request_id: 'refused-principal-token'
}
]);

expect((await eventTypes(pg)).rows).toEqual([
{ name: 'PRINCIPAL_CHILD_WIDENS', category: 'error', feeds_levels: false }
]);
});

it('records the refusal for a human, with the user as actor', async () => {
Expand All @@ -63,11 +68,15 @@ describe('graphql.error (endpoint with an events module)', () => {
const { rows } = await events(pg);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({
name: 'graphql.error',
name: 'PRINCIPAL_CHILD_WIDENS',
actor_id: HUMAN_ID,
payload: { code: 'PRINCIPAL_CHILD_WIDENS', operation: 'WidenChild' },
payload: { operation: 'WidenChild' },
request_id: 'refused-human-token'
});

expect((await eventTypes(pg)).rows).toEqual([
{ name: 'PRINCIPAL_CHILD_WIDENS', category: 'error', feeds_levels: false }
]);
});

it('records nothing for an unauthenticated request', async () => {
Expand Down
3 changes: 3 additions & 0 deletions graphql/server-test/__tests__/error-events.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ export const events = (pg: PgTestClient) =>
pg.query(
`SELECT name, actor_id, payload, request_id FROM "simple-pets-events-public".app_events ORDER BY created_at`
);

export const eventTypes = (pg: PgTestClient) =>
pg.query(`SELECT name, category, feeds_levels FROM "simple-pets-events-public".event_types ORDER BY name`);
34 changes: 26 additions & 8 deletions graphql/server/src/plugins/__tests__/error-events-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import { recordEventSql } from '../error-events-plugin';
import { recordErrorSql } from '../error-events-plugin';

describe('recordEventSql', () => {
describe('recordErrorSql', () => {
it('quotes the events module identifiers', () => {
expect(recordEventSql({ privateSchemaName: 'app_events_private', recordEvent: 'record_event' })).toBe(
'SELECT "app_events_private"."record_event"($1, $2::uuid, $3::jsonb)'
);
expect(
recordErrorSql({
privateSchemaName: 'app_events_private',
recordEvent: 'record_event',
recordError: 'record_error'
})
).toBe('SELECT "app_events_private"."record_error"($1, $2::uuid, $3::jsonb)');
});

it('escapes embedded quotes so identifiers cannot break out', () => {
expect(recordEventSql({ privateSchemaName: 'x"; DROP SCHEMA y; --', recordEvent: 'f' })).toBe(
'SELECT "x""; DROP SCHEMA y; --"."f"($1, $2::uuid, $3::jsonb)'
);
expect(
recordErrorSql({
privateSchemaName: 'x"; DROP SCHEMA y; --',
recordEvent: 'record_event',
recordError: 'f'
})
).toBe('SELECT "x""; DROP SCHEMA y; --"."f"($1, $2::uuid, $3::jsonb)');
});

it('throws for an events module without record_error, so the plugin logs it', () => {
expect(() =>
recordErrorSql({
privateSchemaName: 'app_events_private',
recordEvent: 'record_event',
recordError: null
})
).toThrow('app_events_private has no record_error function');
});
});
41 changes: 21 additions & 20 deletions graphql/server/src/plugins/error-events-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import { normalizeError } from '../middleware/mask-error';

const log = new Logger('error-events');

export const GRAPHQL_ERROR_EVENT = 'graphql.error';

const getExpressRequest = (
requestContext: Partial<Grafast.RequestContext> | undefined
): Request | undefined => (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req;
Expand All @@ -32,25 +30,32 @@ const refusalCode = (errors: readonly GraphQLError[] | undefined): string | unde
return undefined;
};

export const recordEventSql = (events: EventsConfig): string =>
`SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordEvent)}($1, $2::uuid, $3::jsonb)`;
export const recordErrorSql = (events: EventsConfig): string => {
if (!events.recordError) {
throw new Error(`events module ${events.privateSchemaName} has no record_error function`);
}
return `SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordError)}($1, $2::uuid, $3::jsonb)`;
};

/**
* Records `graphql.error` when an authenticated mutation is refused with a
* structured registry code. The refusal rolled back the mutation's own
* transaction, so the event is written afterwards in a fresh transaction under
* the same request claims, via the tenant's events module `record_event`.
* Records a refused authenticated mutation as an event named after the error
* code raised by `errors.raise_error`. The refusal rolled back the mutation's
* own transaction, so the event is written afterwards in a fresh transaction
* under the same request claims, via the tenant's events module
* `record_error`, which classifies the code as an error that earns no ladder
* progress.
*
* The server carries no policy about what a code means: the database reads
* `payload->>'code'` (e.g. PRINCIPAL_CHILD_WIDENS demoting a principal on the
* trust ladder). Endpoints without an events module record nothing.
* Unauthenticated requests are never recorded, so anonymous traffic cannot
* drive writes. The client response is never altered.
* The server carries no policy: the code is the event, and the database decides
* what it means — a ladder's `revoked_by` names the code directly (e.g.
* PRINCIPAL_CHILD_WIDENS demoting a principal on the trust ladder). Endpoints
* without an events module record nothing. Unauthenticated requests are never
* recorded, so anonymous traffic cannot drive writes. The client response is
* never altered.
*/
export const createErrorEventsPlugin = (pool: Pool): GraphileConfig.Plugin => ({
name: 'ErrorEventsPlugin',
version: '0.0.0',
description: 'Records graphql.error through the tenant events module when an authenticated mutation is refused.',
description: 'Records a refused authenticated mutation as its error code through the tenant events module.',

grafast: {
middleware: {
Expand All @@ -74,15 +79,11 @@ export const createErrorEventsPlugin = (pool: Pool): GraphileConfig.Plugin => ({
if (!events || !pgSettings) return result;
const operation = args.operationName ?? getOperationAST(args.document)?.name?.value ?? null;
await withPgClient(pool, pgSettings, (client) =>
client.query(recordEventSql(events), [
GRAPHQL_ERROR_EVENT,
actorId,
JSON.stringify({ code, operation })
])
client.query(recordErrorSql(events), [code, actorId, JSON.stringify({ operation })])
);
} catch (err) {
log.error(
`${label} failed to record ${GRAPHQL_ERROR_EVENT} (${code}) for ${actorId}: ${
`${label} failed to record refusal ${code} for ${actorId}: ${
err instanceof Error ? err.message : String(err)
}`
);
Expand Down
13 changes: 8 additions & 5 deletions packages/express-context/src/loaders/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* Events Module Loader
*
* Resolves the tenant's app-scoped events module from
* metaschema_modules_public.events_module: the private schema and the name of
* its `record_event` function, so the server can record events without
* hard-coding the generated schema or function names.
* metaschema_modules_public.events_module: the private schema and the names of
* its `record_event` and `record_error` functions, so the server can record
* events without hard-coding the generated schema or function names.
*/

import type { EventsConfig } from '../types';
Expand All @@ -16,7 +16,8 @@ import type { LoaderContext, ModuleLoader } from './types';
const EVENTS_MODULE_SQL = `
SELECT
s.schema_name AS private_schema_name,
em.record_event
em.record_event,
em.record_error
FROM metaschema_modules_public.events_module em
JOIN metaschema_public.schema s ON s.id = em.private_schema_id
WHERE em.database_id = $1
Expand All @@ -29,6 +30,7 @@ const EVENTS_MODULE_SQL = `
interface EventsModuleRow {
private_schema_name: string;
record_event: string;
record_error: string | null;
}

// ─── Loader ─────────────────────────────────────────────────────────────────
Expand All @@ -45,7 +47,8 @@ export const eventsLoader: ModuleLoader<EventsConfig> = createModuleLoader<Event

return {
privateSchemaName: row.private_schema_name,
recordEvent: row.record_event
recordEvent: row.record_event,
recordError: row.record_error ?? null
};
}
});
7 changes: 6 additions & 1 deletion packages/express-context/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,15 @@ export interface ComputeConfig {
modules: ComputeModuleConfig[];
}

/** The tenant's app-scoped events module: where `record_event` lives. */
/**
* The tenant's app-scoped events module: where `record_event` and
* `record_error` live. `recordError` is null on a tenant whose events module
* predates the function; callers must report that rather than skip silently.
*/
export interface EventsConfig {
privateSchemaName: string;
recordEvent: string;
recordError: string | null;
}

export interface LlmConfig {
Expand Down
2 changes: 1 addition & 1 deletion pgpm.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@pgpm/database-jobs": "0.44.0",
"@pgpm/function-resolution": "0.44.0",
"@pgpm/jwt-claims": "0.44.0",
"@pgpm/metaschema-modules": "0.44.0",
"@pgpm/metaschema-modules": "0.44.1",
"@pgpm/metaschema-schema": "0.44.0",
"@pgpm/stamps": "0.44.0",
"@pgpm/uuid": "0.44.0"
Expand Down