From a23123fe9e1733290e067503be5e0d18559e137b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 17 Apr 2026 21:00:19 +0000 Subject: [PATCH 1/4] feat: Part C server auth enforcement - access_level, cookie auth, CORS migration, reCAPTCHA C1: access_level enforcement via SET TRANSACTION READ ONLY in graphile middleware C2: Cookie-based session auth as fallback when no Bearer token present C3: CORS migration from api_modules to app_auth_settings.allowed_origins with backward compat C4: reCAPTCHA verification middleware for protected mutations (sign-up, password reset) --- graphql/server/src/middleware/api.ts | 84 +++++++++++++- graphql/server/src/middleware/auth.ts | 29 ++++- graphql/server/src/middleware/captcha.ts | 128 ++++++++++++++++++++++ graphql/server/src/middleware/cors.ts | 11 +- graphql/server/src/middleware/graphile.ts | 19 ++-- graphql/server/src/middleware/types.ts | 2 + graphql/server/src/server.ts | 2 + graphql/server/src/types.ts | 20 ++++ 8 files changed, 276 insertions(+), 19 deletions(-) create mode 100644 graphql/server/src/middleware/captcha.ts diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 5ac900a78..fc6936652 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; -import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types'; +import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types'; import './types'; const log = new Logger('api'); @@ -86,6 +86,26 @@ const RLS_MODULE_SQL = ` LIMIT 1 `; +/** + * Query auth settings from the tenant DB private schema. + * The table name follows the pattern: {privateSchema}.app_settings_auth + * We select only the server-relevant columns. + */ +const AUTH_SETTINGS_SQL = (privateSchema: string) => ` + SELECT + allowed_origins, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age, + cookie_path, + enable_captcha, + captcha_site_key + FROM "${privateSchema}".app_settings_auth + LIMIT 1 +`; + // ============================================================================= // Types // ============================================================================= @@ -111,6 +131,18 @@ interface RlsModuleData { current_user_agent: string; } +interface AuthSettingsRow { + allowed_origins: string[] | null; + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + enable_captcha: boolean; + captcha_site_key: string | null; +} + interface RlsModuleRow { data: RlsModuleData | null; } @@ -208,7 +240,22 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { }; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({ +const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => { + if (!row) return undefined; + return { + allowedOrigins: row.allowed_origins, + cookieSecure: row.cookie_secure, + cookieSamesite: row.cookie_samesite, + cookieDomain: row.cookie_domain, + cookieHttponly: row.cookie_httponly, + cookieMaxAge: row.cookie_max_age, + cookiePath: row.cookie_path, + enableCaptcha: row.enable_captcha, + captchaSiteKey: row.captcha_site_key, + }; +}; + +const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', @@ -219,6 +266,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleR domains: [], databaseId: row.database_id, isPublic: row.is_public, + authSettings: toAuthSettings(authSettingsRow ?? null), }); const createAdminStructure = ( @@ -278,6 +326,28 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise => { + if (!rlsModuleRow?.data?.authenticate_schema) return null; + const privateSchema = rlsModuleRow.data.authenticate_schema; + try { + const tenantPool = getPgPool({ ...opts.pg, database: dbname }); + const result = await tenantPool.query(AUTH_SETTINGS_SQL(privateSchema)); + return result.rows[0] ?? null; + } catch (e: any) { + // Table may not exist yet if the 2FA migration hasn't been applied + log.debug(`[auth-settings] Failed to load auth settings from ${privateSchema}.app_settings_auth: ${e.message}`); + return null; + } +}; + // ============================================================================= // Resolution Logic // ============================================================================= @@ -337,8 +407,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise getNodeEnv() === 'development'; +/** Default cookie name for session tokens. */ +const SESSION_COOKIE_NAME = 'constructive_session'; + +/** + * Extract a named cookie value from the raw Cookie header. + * Avoids pulling in cookie-parser as a dependency. + */ +const parseCookieToken = (req: Request, cookieName: string): string | undefined => { + const header = req.headers.cookie; + if (!header) return undefined; + const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; +}; + export const createAuthenticateMiddleware = ( opts: PgpmOptions ): RequestHandler => { @@ -60,8 +74,15 @@ export const createAuthenticateMiddleware = ( `authType=${authType ?? 'none'}, hasToken=${!!authToken}` ); - if (authType?.toLowerCase() === 'bearer' && authToken) { - log.info('[auth] Processing bearer token authentication'); + // Resolve the credential: prefer Bearer header, fall back to session cookie + const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME); + const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken) + ? authToken + : cookieToken; + const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none'); + + if (effectiveToken) { + log.info(`[auth] Processing ${tokenSource} authentication`); const context: Record = { 'jwt.claims.ip_address': req.clientIp, }; @@ -81,7 +102,7 @@ export const createAuthenticateMiddleware = ( client: pool, context, query: authQuery, - variables: [authToken], + variables: [effectiveToken], }); log.info(`[auth] Query result: rowCount=${result?.rowCount}`); @@ -111,7 +132,7 @@ export const createAuthenticateMiddleware = ( return; } } else { - log.info('[auth] No bearer token provided, using anonymous auth'); + log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth'); } req.token = token; diff --git a/graphql/server/src/middleware/captcha.ts b/graphql/server/src/middleware/captcha.ts new file mode 100644 index 000000000..60f7f90fd --- /dev/null +++ b/graphql/server/src/middleware/captcha.ts @@ -0,0 +1,128 @@ +import { Logger } from '@pgpmjs/logger'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import './types'; // for Request type + +const log = new Logger('captcha'); + +/** Google reCAPTCHA verification endpoint */ +const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + +/** + * Header name the client sends the CAPTCHA response token in. + * Follows the common pattern: X-Captcha-Token. + */ +const CAPTCHA_HEADER = 'x-captcha-token'; + +/** + * GraphQL mutation names that require CAPTCHA verification when enabled. + * Only sign-up and password-reset are gated; normal sign-in is not. + */ +const CAPTCHA_PROTECTED_OPERATIONS = new Set([ + 'signUp', + 'signUpWithMagicLink', + 'signUpWithSms', + 'resetPassword', + 'requestPasswordReset', +]); + +interface RecaptchaResponse { + success: boolean; + 'error-codes'?: string[]; +} + +/** + * Attempt to extract the GraphQL operation name from the request body. + * Works for both JSON and already-parsed bodies. + */ +const getOperationName = (req: Request): string | undefined => { + const body = (req as any).body; + if (!body) return undefined; + // Already parsed (express.json ran first) + if (typeof body === 'object' && body.operationName) { + return body.operationName; + } + return undefined; +}; + +/** + * Verify a reCAPTCHA token with Google's API. + */ +const verifyToken = async (token: string, secretKey: string): Promise => { + try { + const params = new URLSearchParams({ secret: secretKey, response: token }); + const res = await fetch(RECAPTCHA_VERIFY_URL, { + method: 'POST', + body: params, + }); + const data = (await res.json()) as RecaptchaResponse; + if (!data.success) { + log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`); + } + return data.success; + } catch (e: any) { + log.error('[captcha] Error verifying token:', e.message); + return false; + } +}; + +/** + * Creates a CAPTCHA verification middleware. + * + * When `enable_captcha` is true in app_auth_settings, this middleware checks + * the X-Captcha-Token header on protected mutations (sign-up, password reset). + * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable + * (the public site key is stored in app_auth_settings for the frontend). + * + * Skips verification when: + * - CAPTCHA is not enabled in auth settings + * - The request is not a protected mutation + * - No secret key is configured server-side + */ +export const createCaptchaMiddleware = (): RequestHandler => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const authSettings = req.api?.authSettings; + + // Skip if CAPTCHA is not enabled + if (!authSettings?.enableCaptcha) { + return next(); + } + + // Only gate protected operations + const opName = getOperationName(req); + if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) { + return next(); + } + + // Secret key must be set server-side (env var, not stored in DB for security) + const secretKey = process.env.RECAPTCHA_SECRET_KEY; + if (!secretKey) { + log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification'); + return next(); + } + + const captchaToken = req.get(CAPTCHA_HEADER); + if (!captchaToken) { + res.status(200).json({ + errors: [{ + message: 'CAPTCHA verification required', + extensions: { code: 'CAPTCHA_REQUIRED' }, + }], + }); + return; + } + + const valid = await verifyToken(captchaToken, secretKey); + if (!valid) { + res.status(200).json({ + errors: [{ + message: 'CAPTCHA verification failed', + extensions: { code: 'CAPTCHA_FAILED' }, + }], + }); + return; + } + + log.info(`[captcha] Verified for operation=${opName}`); + next(); + }; +}; diff --git a/graphql/server/src/middleware/cors.ts b/graphql/server/src/middleware/cors.ts index f7980f72f..018d0775b 100644 --- a/graphql/server/src/middleware/cors.ts +++ b/graphql/server/src/middleware/cors.ts @@ -33,11 +33,18 @@ export const cors = (fallbackOrigin?: string): RequestHandler => { // 2) Per-API allowlist sourced from req.api (if available) // createApiMiddleware runs before this in server.ts, so req.api should be set - const api = (req as any).api as { apiModules?: any[]; domains?: string[] } | undefined; + const api = (req as any).api as { apiModules?: any[]; domains?: string[]; authSettings?: { allowedOrigins?: string[] | null } } | undefined; if (api) { + // Preferred: app_auth_settings.allowed_origins (new approach) + const settingsOrigins = api.authSettings?.allowedOrigins || []; + // Legacy: api_modules CORS entries (backward compat) const corsModules = (api.apiModules || []).filter((m: any) => m.name === 'cors') as { name: 'cors'; data: CorsModuleData }[]; const siteUrls = api.domains || []; - const listOfDomains = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], siteUrls); + const listOfDomains = [ + ...settingsOrigins, + ...corsModules.reduce((m, mod) => [...mod.data.urls, ...m], []), + ...siteUrls, + ]; if (origin && listOfDomains.includes(origin)) { return callback(null, true); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index aa365e17a..587a59beb 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -182,14 +182,19 @@ const buildPreset = ( } if (req.token?.user_id) { - return { - pgSettings: { - role: roleName, - 'jwt.claims.token_id': req.token.id, - 'jwt.claims.user_id': req.token.user_id, - ...context, - }, + const pgSettings: Record = { + role: roleName, + 'jwt.claims.token_id': req.token.id, + 'jwt.claims.user_id': req.token.user_id, + ...context, }; + + // Enforce read-only transactions for read_only credentials (API keys, etc.) + if (req.token.access_level === 'read_only') { + pgSettings['default_transaction_read_only'] = 'on'; + } + + return { pgSettings }; } } diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index eb682a484..0d22e982c 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -3,6 +3,8 @@ import type { ApiStructure } from '../types'; export type ConstructiveAPIToken = { id?: string; user_id?: string; + access_level?: string; + kind?: string; [key: string]: unknown; }; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 7aa57c4b2..63c9ceada 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -32,6 +32,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug- import { debugMemory } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; +import { createCaptchaMiddleware } from './middleware/captcha'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -158,6 +159,7 @@ class Server { app.use(api); app.post('/upload', uploadAuthenticate, ...uploadRoute); app.use(authenticate); + app.use(createCaptchaMiddleware()); app.use(graphile(effectiveOpts)); app.use(flush); diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index fffdc3221..b588f5e94 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -38,6 +38,25 @@ export interface RlsModule { currentUserAgent: string; } +/** + * Server-visible subset of app_auth_settings (lives in the tenant DB private schema). + * Loaded once per API resolution and cached alongside the ApiStructure. + */ +export interface AuthSettings { + /** CORS allowed origins from app_auth_settings.allowed_origins */ + allowedOrigins?: string[] | null; + /** Cookie configuration */ + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + /** reCAPTCHA / CAPTCHA */ + enableCaptcha?: boolean; + captchaSiteKey?: string | null; +} + export interface ApiStructure { apiId?: string; dbname: string; @@ -49,6 +68,7 @@ export interface ApiStructure { domains?: string[]; databaseId?: string; isPublic?: boolean; + authSettings?: AuthSettings; } export type ApiError = { errorHtml: string }; From 28884c2f3497608c33533fce2a112fe838ba4d7e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 17 Apr 2026 22:04:31 +0000 Subject: [PATCH 2/4] fix: discover auth settings via metaschema modules, remove CORS migration - Replace hardcoded app_settings_auth table name with dynamic discovery via metaschema_modules_public.sessions_module -> metaschema.schema_and_table() - Remove allowed_origins from AuthSettings (CORS migration deferred) - Revert cors.ts to legacy api_modules-only approach --- graphql/server/src/middleware/api.ts | 68 +++++++++++++++++++-------- graphql/server/src/middleware/cors.ts | 11 +---- graphql/server/src/types.ts | 3 +- 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index fc6936652..84b1f481f 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -87,13 +87,27 @@ const RLS_MODULE_SQL = ` `; /** - * Query auth settings from the tenant DB private schema. - * The table name follows the pattern: {privateSchema}.app_settings_auth - * We select only the server-relevant columns. + * Discover auth settings table location via metaschema modules. + * Step 1: Get auth_settings_table_id from sessions_module + * Step 2: Resolve the actual schema + table name via metaschema.schema_and_table() */ -const AUTH_SETTINGS_SQL = (privateSchema: string) => ` +const AUTH_SETTINGS_TABLE_ID_SQL = ` + SELECT auth_settings_table_id + FROM metaschema_modules_public.sessions_module + LIMIT 1 +`; + +const AUTH_SETTINGS_SCHEMA_AND_TABLE_SQL = ` + SELECT schema_name, table_name + FROM metaschema.schema_and_table($1) +`; + +/** + * Query auth settings from the discovered table. + * Schema and table name are resolved dynamically from metaschema modules. + */ +const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => ` SELECT - allowed_origins, cookie_secure, cookie_samesite, cookie_domain, @@ -102,7 +116,7 @@ const AUTH_SETTINGS_SQL = (privateSchema: string) => ` cookie_path, enable_captcha, captcha_site_key - FROM "${privateSchema}".app_settings_auth + FROM "${schemaName}"."${tableName}" LIMIT 1 `; @@ -132,7 +146,6 @@ interface RlsModuleData { } interface AuthSettingsRow { - allowed_origins: string[] | null; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; @@ -243,7 +256,6 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => { if (!row) return undefined; return { - allowedOrigins: row.allowed_origins, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, @@ -327,23 +339,41 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise => { - if (!rlsModuleRow?.data?.authenticate_schema) return null; - const privateSchema = rlsModuleRow.data.authenticate_schema; try { const tenantPool = getPgPool({ ...opts.pg, database: dbname }); - const result = await tenantPool.query(AUTH_SETTINGS_SQL(privateSchema)); + + // Step 1: Get auth_settings_table_id from sessions_module + const modResult = await tenantPool.query<{ auth_settings_table_id: string }>(AUTH_SETTINGS_TABLE_ID_SQL); + const tableId = modResult.rows[0]?.auth_settings_table_id; + if (!tableId) { + log.debug('[auth-settings] No sessions_module row found in tenant DB'); + return null; + } + + // Step 2: Resolve actual schema + table name + const stResult = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_SCHEMA_AND_TABLE_SQL, [tableId]); + const resolved = stResult.rows[0]; + if (!resolved) { + log.debug(`[auth-settings] Could not resolve schema_and_table for table_id=${tableId}`); + return null; + } + + // Step 3: Query the actual auth settings table + const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name)); return result.rows[0] ?? null; } catch (e: any) { - // Table may not exist yet if the 2FA migration hasn't been applied - log.debug(`[auth-settings] Failed to load auth settings from ${privateSchema}.app_settings_auth: ${e.message}`); + // Table/module may not exist yet if the 2FA migration hasn't been applied + log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`); return null; } }; @@ -407,7 +437,7 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise { // 2) Per-API allowlist sourced from req.api (if available) // createApiMiddleware runs before this in server.ts, so req.api should be set - const api = (req as any).api as { apiModules?: any[]; domains?: string[]; authSettings?: { allowedOrigins?: string[] | null } } | undefined; + const api = (req as any).api as { apiModules?: any[]; domains?: string[] } | undefined; if (api) { - // Preferred: app_auth_settings.allowed_origins (new approach) - const settingsOrigins = api.authSettings?.allowedOrigins || []; - // Legacy: api_modules CORS entries (backward compat) const corsModules = (api.apiModules || []).filter((m: any) => m.name === 'cors') as { name: 'cors'; data: CorsModuleData }[]; const siteUrls = api.domains || []; - const listOfDomains = [ - ...settingsOrigins, - ...corsModules.reduce((m, mod) => [...mod.data.urls, ...m], []), - ...siteUrls, - ]; + const listOfDomains = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], siteUrls); if (origin && listOfDomains.includes(origin)) { return callback(null, true); diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index b588f5e94..fdb50f0c0 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -40,11 +40,10 @@ export interface RlsModule { /** * Server-visible subset of app_auth_settings (lives in the tenant DB private schema). + * Discovered dynamically via metaschema_modules_public.sessions_module. * Loaded once per API resolution and cached alongside the ApiStructure. */ export interface AuthSettings { - /** CORS allowed origins from app_auth_settings.allowed_origins */ - allowedOrigins?: string[] | null; /** Cookie configuration */ cookieSecure?: boolean; cookieSamesite?: string; From 17dbfc34d46df956c9e79a7f8bb40b2ce83b5c92 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 17 Apr 2026 22:15:57 +0000 Subject: [PATCH 3/4] fix: use public metaschema tables for auth settings discovery Replace metaschema.schema_and_table() (private schema) with a JOIN through metaschema_modules_public.sessions_module and metaschema_public.schema (both public). Also reduces from 3 queries to 2 by combining the module lookup and schema resolution into a single query. --- graphql/server/src/middleware/api.ts | 43 ++++++++++------------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 84b1f481f..430d9e53a 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -87,21 +87,17 @@ const RLS_MODULE_SQL = ` `; /** - * Discover auth settings table location via metaschema modules. - * Step 1: Get auth_settings_table_id from sessions_module - * Step 2: Resolve the actual schema + table name via metaschema.schema_and_table() + * Discover auth settings table location via public metaschema tables. + * Joins sessions_module with metaschema_public.schema to resolve + * the schema name + table name without touching private schemas. */ -const AUTH_SETTINGS_TABLE_ID_SQL = ` - SELECT auth_settings_table_id - FROM metaschema_modules_public.sessions_module +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id LIMIT 1 `; -const AUTH_SETTINGS_SCHEMA_AND_TABLE_SQL = ` - SELECT schema_name, table_name - FROM metaschema.schema_and_table($1) -`; - /** * Query auth settings from the discovered table. * Schema and table name are resolved dynamically from metaschema modules. @@ -340,10 +336,9 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise(AUTH_SETTINGS_TABLE_ID_SQL); - const tableId = modResult.rows[0]?.auth_settings_table_id; - if (!tableId) { - log.debug('[auth-settings] No sessions_module row found in tenant DB'); - return null; - } - - // Step 2: Resolve actual schema + table name - const stResult = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_SCHEMA_AND_TABLE_SQL, [tableId]); - const resolved = stResult.rows[0]; + // Discover the auth settings schema + table name from public metaschema tables + const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_DISCOVERY_SQL); + const resolved = discovery.rows[0]; if (!resolved) { - log.debug(`[auth-settings] Could not resolve schema_and_table for table_id=${tableId}`); + log.debug('[auth-settings] No sessions_module row found in tenant DB'); return null; } - // Step 3: Query the actual auth settings table + // Query the discovered auth settings table const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name)); return result.rows[0] ?? null; } catch (e: any) { From 788fdfed6ccae525e1ba8183b691df25d6e16f53 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 17 Apr 2026 22:34:03 +0000 Subject: [PATCH 4/4] feat: propagate kind + access_level as JWT claims in pgSettings Adds jwt.claims.access_level and jwt.claims.kind to PostgreSQL session settings so PG functions can read them via current_setting(). This lets the DB layer make decisions based on credential type (api_key vs session) and access level (read_write vs read_only) without additional lookups. --- graphql/server/src/middleware/graphile.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 587a59beb..4e3bb64e4 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -189,6 +189,15 @@ const buildPreset = ( ...context, }; + // Propagate credential metadata as JWT claims so PG functions + // can read them via current_setting('jwt.claims.access_level') etc. + if (req.token.access_level) { + pgSettings['jwt.claims.access_level'] = req.token.access_level; + } + if (req.token.kind) { + pgSettings['jwt.claims.kind'] = req.token.kind; + } + // Enforce read-only transactions for read_only credentials (API keys, etc.) if (req.token.access_level === 'read_only') { pgSettings['default_transaction_read_only'] = 'on';