From bb9b814b3c874e763ba99ade430260ac1b8c58ef Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Feb 2026 02:16:39 +0000 Subject: [PATCH 1/6] feat(api): add RLS module support to API resolution - Add RLS_MODULE_SQL query to fetch RLS module data with private schema name - Add api_id to DOMAIN_LOOKUP_SQL and API_NAME_LOOKUP_SQL queries - Add RlsModuleRow interface for type safety - Add queryRlsModule function to fetch RLS module by API ID - Add toRlsModule helper to convert database row to RlsModule interface - Update toApiStructure to accept and include RLS module data - Update resolveApiNameHeader and resolveDomainLookup to fetch RLS module This enables the authentication middleware (auth.ts) to access the rlsModule data (authenticate, authenticateStrict, privateSchema) which is required for PostGraphile v5 authentication flow. --- graphql/server/src/middleware/api.ts | 56 +++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 23d4f9777..6428a7223 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 } from '../types'; +import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types'; import './types'; const log = new Logger('api'); @@ -20,6 +20,7 @@ const isDev = () => getNodeEnv() === 'development'; const DOMAIN_LOOKUP_SQL = ` SELECT + a.id as api_id, a.database_id, a.dbname, a.role_name, @@ -39,6 +40,7 @@ const DOMAIN_LOOKUP_SQL = ` const API_NAME_LOOKUP_SQL = ` SELECT + a.id as api_id, a.database_id, a.dbname, a.role_name, @@ -77,11 +79,23 @@ const API_LIST_SQL = ` LIMIT 100 `; +const RLS_MODULE_SQL = ` + SELECT + rm.authenticate, + rm.authenticate_strict, + ps.schema_name as private_schema_name + FROM services_public.rls_module rm + LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id + WHERE rm.api_id = $1 + LIMIT 1 +`; + // ============================================================================= // Types // ============================================================================= interface ApiRow { + api_id: string; database_id: string; dbname: string; role_name: string; @@ -90,6 +104,12 @@ interface ApiRow { schemas: string[]; } +interface RlsModuleRow { + authenticate: string | null; + authenticate_strict: string | null; + private_schema_name: string | null; +} + interface ApiListRow { id: string; database_id: string; @@ -164,12 +184,24 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { return baseKey; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions): ApiStructure => ({ +const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { + if (!row || !row.private_schema_name) return undefined; + return { + authenticate: row.authenticate ?? undefined, + authenticateStrict: row.authenticate_strict ?? undefined, + privateSchema: { + schemaName: row.private_schema_name, + }, + }; +}; + +const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({ dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', roleName: row.role_name || 'authenticated', schema: row.schemas || [], apiModules: [], + rlsModule: toRlsModule(rlsModuleRow ?? null), domains: [], databaseId: row.database_id, isPublic: row.is_public, @@ -242,6 +274,16 @@ const queryApiList = async (pool: Pool, isPublic: boolean): Promise => { + try { + const result = await pool.query(RLS_MODULE_SQL, [apiId]); + return result.rows[0] ?? null; + } catch (err: unknown) { + if ((err as Error).message?.includes('does not exist')) return null; + throw err; + } +}; + // ============================================================================= // Resolution Logic // ============================================================================= @@ -300,8 +342,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise Date: Thu, 5 Feb 2026 02:24:19 +0000 Subject: [PATCH 2/6] fix(graphile): use correct requestContext path for Express v4 in PostGraphile v5 The grafast.context callback receives a RequestContext object, not a generic context. In grafserv/express/v4, the Express request is available at requestContext.expressv4.req, not ctx.node.req. This was preventing the authentication middleware from properly passing the token to the GraphQL context, causing bearer token authentication to fail silently (always using anonRole instead of roleName). --- graphql/server/src/middleware/graphile.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index ff4b4fec1..d27bdd961 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -108,8 +108,9 @@ const createGraphileInstance = async ( }, grafast: { explain: process.env.NODE_ENV === 'development', - context: (ctx: unknown) => { - const req = (ctx as { node?: { req?: Request } } | undefined)?.node?.req; + context: (requestContext: Grafast.RequestContext) => { + // In grafserv/express/v4, the request is available at requestContext.expressv4.req + const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; const context: Record = {}; if (req) { From 2c111144f894d07503f83871c592ef25853706e3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Feb 2026 02:28:13 +0000 Subject: [PATCH 3/6] fix(graphile): use correct Partial type signature The grafast.context callback expects Partial, not the full Grafast.RequestContext type. --- graphql/server/src/middleware/graphile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index d27bdd961..df4dbb1f5 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -108,7 +108,7 @@ const createGraphileInstance = async ( }, grafast: { explain: process.env.NODE_ENV === 'development', - context: (requestContext: Grafast.RequestContext) => { + context: (requestContext: Partial) => { // In grafserv/express/v4, the request is available at requestContext.expressv4.req const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; const context: Record = {}; From d655590b236b4d6e074d40a4f3c7838e9d0276ad Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Feb 2026 02:33:59 +0000 Subject: [PATCH 4/6] feat(auth): add comprehensive logging to trace authentication flow Added INFO-level logging throughout the auth middleware to help debug authentication issues: - Log when middleware is called and whether api is present - Log RLS module details (authenticate, authenticateStrict, privateSchema) - Log authFn selection and strictAuth setting - Log authorization header parsing - Log the actual auth query being executed - Log query results and success/failure - Log when skipping auth due to missing config --- graphql/server/src/middleware/auth.ts | 41 +++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/graphql/server/src/middleware/auth.ts b/graphql/server/src/middleware/auth.ts index 72448bad5..76580c516 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -18,6 +18,7 @@ export const createAuthenticateMiddleware = ( next: NextFunction ): Promise => { const api = req.api; + log.info(`[auth] middleware called, api=${api ? 'present' : 'missing'}`); if (!api) { res.status(500).send('Missing API info'); return; @@ -29,21 +30,38 @@ export const createAuthenticateMiddleware = ( }); const rlsModule = api.rlsModule; + log.info( + `[auth] rlsModule=${rlsModule ? 'present' : 'missing'}, ` + + `authenticate=${rlsModule?.authenticate ?? 'none'}, ` + + `authenticateStrict=${rlsModule?.authenticateStrict ?? 'none'}, ` + + `privateSchema=${rlsModule?.privateSchema?.schemaName ?? 'none'}` + ); + if (!rlsModule) { - if (isDev()) log.debug('No RLS module configured, skipping auth'); + log.info('[auth] No RLS module configured, skipping auth'); return next(); } - const authFn = opts.server.strictAuth + const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate; + log.info( + `[auth] strictAuth=${opts.server?.strictAuth ?? false}, authFn=${authFn ?? 'none'}` + ); + if (authFn && rlsModule.privateSchema.schemaName) { const { authorization = '' } = req.headers; const [authType, authToken] = authorization.split(' '); let token: any = {}; + log.info( + `[auth] authorization header present=${!!authorization}, ` + + `authType=${authType ?? 'none'}, hasToken=${!!authToken}` + ); + if (authType?.toLowerCase() === 'bearer' && authToken) { + log.info('[auth] Processing bearer token authentication'); const context: Record = { 'jwt.claims.ip_address': req.clientIp, }; @@ -55,15 +73,21 @@ export const createAuthenticateMiddleware = ( context['jwt.claims.user_agent'] = req.get('User-Agent'); } + const authQuery = `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`; + log.info(`[auth] Executing auth query: ${authQuery}`); + try { const result = await pgQueryContext({ client: pool, context, - query: `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`, + query: authQuery, variables: [authToken], }); + log.info(`[auth] Query result: rowCount=${result?.rowCount}`); + if (result?.rowCount === 0) { + log.info('[auth] No rows returned, returning UNAUTHENTICATED'); res.status(200).json({ errors: [{ extensions: { code: 'UNAUTHENTICATED' } }], }); @@ -71,9 +95,9 @@ export const createAuthenticateMiddleware = ( } token = result.rows[0]; - if (isDev()) log.debug(`Auth success: role=${token.role}`); + log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`); } catch (e: any) { - log.error('Auth error:', e.message); + log.error('[auth] Auth error:', e.message); res.status(200).json({ errors: [ { @@ -86,9 +110,16 @@ export const createAuthenticateMiddleware = ( }); return; } + } else { + log.info('[auth] No bearer token provided, using anonymous auth'); } req.token = token; + } else { + log.info( + `[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` + + `privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}` + ); } next(); From 7e28ce6c83635ccf564f1493fd765bd5f80e2fe3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Feb 2026 02:57:28 +0000 Subject: [PATCH 5/6] fix(api): query RLS module from correct schema metaschema_modules_public The RLS module table is in metaschema_modules_public.rls_module, not services_public.rls_module. This was causing the RLS module query to return null, which made authentication skip entirely. --- graphql/server/src/middleware/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 6428a7223..b6b8b2aba 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -84,7 +84,7 @@ const RLS_MODULE_SQL = ` rm.authenticate, rm.authenticate_strict, ps.schema_name as private_schema_name - FROM services_public.rls_module rm + FROM metaschema_modules_public.rls_module rm LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id WHERE rm.api_id = $1 LIMIT 1 From cf0c375901f963692cb38e5d17f0ac1d8ab97f3c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Feb 2026 03:07:40 +0000 Subject: [PATCH 6/6] refactor(api): remove silent error swallowing from query functions Simplified query functions to just call pool.query() directly without try/catch blocks that silently swallow 'does not exist' errors. Errors should propagate so issues are visible, not hidden. --- graphql/server/src/middleware/api.ts | 36 +++++++--------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index b6b8b2aba..13259910d 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -240,13 +240,8 @@ const queryByDomain = async ( subdomain: string | null, isPublic: boolean ): Promise => { - try { - const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]); - return result.rows[0] ?? null; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return null; - throw err; - } + const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]); + return result.rows[0] ?? null; }; const queryByApiName = async ( @@ -255,33 +250,18 @@ const queryByApiName = async ( name: string, isPublic: boolean ): Promise => { - try { - const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]); - return result.rows[0] ?? null; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return null; - throw err; - } + const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]); + return result.rows[0] ?? null; }; const queryApiList = async (pool: Pool, isPublic: boolean): Promise => { - try { - const result = await pool.query(API_LIST_SQL, [isPublic]); - return result.rows; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return []; - throw err; - } + const result = await pool.query(API_LIST_SQL, [isPublic]); + return result.rows; }; const queryRlsModule = async (pool: Pool, apiId: string): Promise => { - try { - const result = await pool.query(RLS_MODULE_SQL, [apiId]); - return result.rows[0] ?? null; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return null; - throw err; - } + const result = await pool.query(RLS_MODULE_SQL, [apiId]); + return result.rows[0] ?? null; }; // =============================================================================