From 23b48dd5bbe1a3384c7d561565d95ed9d179cb3e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 23 Sep 2026 10:15:32 +0000 Subject: [PATCH 1/2] feat(graphql-server): stamp actor entity attribution on token sessions Authenticated public GraphQL requests set jwt.claims.user_id/principal_id but never jwt.claims.entity_id/entity_type, so work created by a signed-in user (e.g. startExecution) had no entity attribution. Resolve the pair server-side via app_scope.actor_entity(database_id, user_id) in the auth middleware (principal credentials resolve to their owner) and stamp it in the token branch of the graphile context. Tenant databases without app_scope are unchanged; when it is present an unresolvable actor fails the request rather than proceeding entityless. --- .../middleware/__tests__/actor-entity.test.ts | 151 ++++++++++++++++++ graphql/server/src/middleware/actor-entity.ts | 79 +++++++++ graphql/server/src/middleware/auth.ts | 22 +++ graphql/server/src/middleware/graphile.ts | 7 + graphql/server/src/middleware/types.ts | 8 + 5 files changed, 267 insertions(+) create mode 100644 graphql/server/src/middleware/__tests__/actor-entity.test.ts create mode 100644 graphql/server/src/middleware/actor-entity.ts diff --git a/graphql/server/src/middleware/__tests__/actor-entity.test.ts b/graphql/server/src/middleware/__tests__/actor-entity.test.ts new file mode 100644 index 0000000000..162325f7d1 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/actor-entity.test.ts @@ -0,0 +1,151 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { ActorEntityError, createActorEntityResolver } from '../actor-entity'; + +const mockQuery = jest.fn(); +const mockPgQueryContext = jest.fn(); + +jest.mock('pg-cache', () => ({ + getPgPool: () => ({ query: mockQuery }) +})); +jest.mock('pg-query-context', () => ({ + __esModule: true, + default: (args: any) => mockPgQueryContext(args) +})); +jest.mock('@pgpmjs/env', () => ({ getNodeEnv: () => 'test' })); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createAuthenticateMiddleware } = require('../auth'); + +describe('createActorEntityResolver', () => { + it('resolves and caches the entity pair per database + actor', async () => { + const query = jest.fn().mockResolvedValue({ entity_id: 'owner-1', entity_type: 'app' }); + const resolve = createActorEntityResolver({ query, ttlMs: 1000, now: () => 0 }); + + await expect(resolve('db-1', 'actor-1')).resolves.toEqual({ entityId: 'owner-1', entityType: 'app' }); + await expect(resolve('db-1', 'actor-1')).resolves.toEqual({ entityId: 'owner-1', entityType: 'app' }); + expect(query).toHaveBeenCalledTimes(1); + + await resolve('db-2', 'actor-1'); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('expires cached entries after the ttl', async () => { + let clock = 0; + const query = jest.fn().mockResolvedValue({ entity_id: 'owner-1', entity_type: 'org' }); + const resolve = createActorEntityResolver({ query, ttlMs: 10, now: () => clock }); + + await resolve('db-1', 'actor-1'); + clock = 11; + await resolve('db-1', 'actor-1'); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('throws when the actor does not resolve to an entity', async () => { + const resolve = createActorEntityResolver({ query: jest.fn().mockResolvedValue(undefined) }); + await expect(resolve('db-1', 'ghost')).rejects.toBeInstanceOf(ActorEntityError); + }); +}); + +describe('authenticate middleware entity attribution', () => { + const api = { + dbname: 'tenant_db', + databaseId: 'db-1', + rlsModule: { + authenticate: 'authenticate', + authenticateStrict: 'authenticate_strict', + privateSchema: { schemaName: 'app_private' } + } + }; + + const run = async (authorization?: string) => { + const middleware = createAuthenticateMiddleware({ pg: {}, server: {} } as any); + const req = { + api, + headers: authorization ? { authorization } : {}, + get: (): string | undefined => undefined, + clientIp: '127.0.0.1' + } as unknown as Request; + const res = { + status: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis() + } as unknown as Response; + const next = jest.fn() as NextFunction; + await middleware(req, res, next); + return { req, res, next }; + }; + + const PROBE = /to_regprocedure\('app_scope\.actor_entity/; + const withAppScope = (present: boolean, row?: { entity_id: string; entity_type: string }) => + mockQuery.mockImplementation(async (sql: string) => + PROBE.test(sql) ? { rows: [{ present }] } : { rows: row ? [row] : [] } + ); + const entityCalls = () => mockQuery.mock.calls.filter(([sql]) => !PROBE.test(sql)); + + beforeEach(() => { + mockQuery.mockReset(); + mockPgQueryContext.mockReset(); + }); + + it('stamps the actor entity resolved through app_scope.actor_entity for a user token', async () => { + mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1', role: 'authenticated' }] }); + withAppScope(true, { entity_id: 'user-1', entity_type: 'app' }); + + const { req, next } = await run('Bearer tok'); + + expect(entityCalls()).toEqual([ + [expect.stringContaining('app_scope.actor_entity($1, $2)'), ['db-1', 'user-1']] + ]); + expect(req.actorEntity).toEqual({ entityId: 'user-1', entityType: 'app' }); + expect(next).toHaveBeenCalled(); + }); + + it("attributes a principal credential to its owner's entity pair", async () => { + mockPgQueryContext.mockResolvedValue({ + rowCount: 1, + rows: [{ user_id: 'principal-1', principal_id: 'principal-1', kind: 'principal' }] + }); + withAppScope(true, { entity_id: 'org-1', entity_type: 'org' }); + + const { req } = await run('Bearer tok'); + + expect(entityCalls()).toEqual([[expect.any(String), ['db-1', 'principal-1']]]); + expect(req.actorEntity).toEqual({ entityId: 'org-1', entityType: 'org' }); + }); + + it('fails the request instead of continuing entityless when the actor cannot be resolved', async () => { + mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] }); + withAppScope(true); + + const { req, res, next } = await run('Bearer tok'); + + expect(req.actorEntity).toBeUndefined(); + expect(next).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + errors: [expect.objectContaining({ extensions: expect.objectContaining({ code: 'INTERNAL_FAILURE' }) })] + }) + ); + }); + + it('stamps nothing when the tenant database has no app_scope', async () => { + mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] }); + withAppScope(false); + + const { req, next } = await run('Bearer tok'); + + expect(entityCalls()).toEqual([]); + expect(req.actorEntity).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + + it('leaves anonymous requests without an actor entity', async () => { + const { req, next } = await run(); + + expect(mockQuery).not.toHaveBeenCalled(); + expect(req.actorEntity).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/graphql/server/src/middleware/actor-entity.ts b/graphql/server/src/middleware/actor-entity.ts new file mode 100644 index 0000000000..53238bcf50 --- /dev/null +++ b/graphql/server/src/middleware/actor-entity.ts @@ -0,0 +1,79 @@ +/** + * actor-entity — Resolve the entity pair an authenticated actor works on + * behalf of. + * + * `entity_id` / `entity_type` are attribution, not authorization: they name + * the user or org that owns (and is billed for) work created in the request. + * The pair is resolved server-side through `app_scope.actor_entity`, which + * maps a principal credential to its owner — a principal is never an entity + * of its own. Callers cannot supply the pair on the public surface. + * + * `app_scope` ships with the Constructive platform modules; a tenant database + * without it (a bare RLS module) has no entity model, so no pair is stamped. + * Once the resolver is present, a session that cannot be attributed is a + * hard failure — work is never created entityless. + */ + +import type { Pool } from 'pg'; + +export interface ActorEntity { + entityId: string; + entityType: string; +} + +export type ActorEntityQuery = ( + databaseId: string, + actorId: string +) => Promise<{ entity_id: string; entity_type: string } | undefined>; + +export interface ActorEntityResolverOptions { + query: ActorEntityQuery; + ttlMs?: number; + now?: () => number; +} + +const DEFAULT_TTL_MS = 60_000; + +export class ActorEntityError extends Error { + readonly code = 'ACTOR_ENTITY_UNRESOLVED'; +} + +export const createActorEntityResolver = (opts: ActorEntityResolverOptions) => { + const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; + const now = opts.now ?? Date.now; + const cache = new Map(); + + return async (databaseId: string, actorId: string): Promise => { + const key = `${databaseId}:${actorId}`; + const hit = cache.get(key); + if (hit && hit.expiresAt > now()) { + return hit.value; + } + const row = await opts.query(databaseId, actorId); + if (!row?.entity_id || !row?.entity_type) { + throw new ActorEntityError( + `actor ${actorId} in database ${databaseId} does not resolve to an entity` + ); + } + const value: ActorEntity = { entityId: row.entity_id, entityType: row.entity_type }; + cache.set(key, { value, expiresAt: now() + ttlMs }); + return value; + }; +}; + +/** Whether the tenant database exposes `app_scope.actor_entity`. */ +export const hasActorEntityResolver = async (pool: Pool): Promise => { + const result = await pool.query<{ present: boolean }>( + "SELECT to_regprocedure('app_scope.actor_entity(uuid, uuid)') IS NOT NULL AS present" + ); + return result.rows[0]?.present === true; +}; + +export const pgActorEntityQuery = (pool: Pool): ActorEntityQuery => + async (databaseId, actorId) => { + const result = await pool.query<{ entity_id: string; entity_type: string }>( + 'SELECT entity_id, entity_type FROM app_scope.actor_entity($1, $2)', + [databaseId, actorId] + ); + return result.rows[0]; + }; diff --git a/graphql/server/src/middleware/auth.ts b/graphql/server/src/middleware/auth.ts index ef6da3f3a2..5ee73ecc41 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -9,6 +9,7 @@ import { getPgPool } from 'pg-cache'; import pgQueryContext from 'pg-query-context'; import { respondWithGraphQLError } from '../errors/graphql-response'; +import { createActorEntityResolver, hasActorEntityResolver, pgActorEntityQuery } from './actor-entity'; const log = new Logger('auth'); const isDev = () => getNodeEnv() === 'development'; @@ -33,6 +34,20 @@ const parseCookieToken = (req: Request, cookieName: string): string | undefined export const createAuthenticateMiddleware = ( opts: PgpmOptions ): RequestHandler => { + type Resolver = ReturnType | null; + const resolvers = new Map>(); + const actorEntityResolver = (dbname: string, pool: Parameters[0]): Promise => { + let resolver = resolvers.get(dbname); + if (!resolver) { + resolver = hasActorEntityResolver(pool).then((present) => + present ? createActorEntityResolver({ query: pgActorEntityQuery(pool) }) : null + ); + resolver.catch(() => resolvers.delete(dbname)); + resolvers.set(dbname, resolver); + } + return resolver; + }; + return async ( req: Request, res: Response, @@ -122,6 +137,13 @@ export const createAuthenticateMiddleware = ( token = result.rows[0]; log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`); + + if (token?.user_id && api.databaseId) { + const resolve = await actorEntityResolver(api.dbname, pool); + if (resolve) { + req.actorEntity = await resolve(api.databaseId, token.user_id); + } + } } catch (e: any) { log.error('[auth] Auth error:', e.message); respondWithGraphQLError( diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e888d0acff..cf31e5c460 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -204,6 +204,13 @@ const buildPreset = async ( // Principal identity — always set; equals user_id for human sessions pgSettings['jwt.claims.principal_id'] = req.token.principal_id || req.token.user_id; + // Entity attribution — resolved server-side by the auth middleware; + // a principal credential resolves to its owner's entity pair + if (req.actorEntity) { + pgSettings['jwt.claims.entity_id'] = req.actorEntity.entityId; + pgSettings['jwt.claims.entity_type'] = req.actorEntity.entityType; + } + // Enforce read-only transactions for read_only credentials if (req.token.access_level === 'read_only') { pgSettings['default_transaction_read_only'] = 'on'; diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 2afecf348b..9384e7dac5 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -1,5 +1,7 @@ import type { ApiStructure, ConstructiveAPIToken, RequestProtection } from '@constructive-io/express-context'; +import type { ActorEntity } from './actor-entity'; + export type { ConstructiveAPIToken } from '@constructive-io/express-context'; declare global { @@ -11,6 +13,12 @@ declare global { databaseId?: string; requestId?: string; token?: ConstructiveAPIToken; + /** + * Entity the authenticated actor works on behalf of, resolved + * server-side via `app_scope.actor_entity`. Set by the auth + * middleware for token sessions; never taken from the client. + */ + actorEntity?: ActorEntity; /** Device token from constructive_device_token cookie for trusted device tracking */ deviceToken?: string; /** From a6885c8056d9570ec34f3c1ab1c97966b52d2878 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 23 Sep 2026 21:24:09 +0000 Subject: [PATCH 2/2] fix(graphql-server): attribute only databases that install a users module; re-probe the entity model on a TTL --- .../middleware/__tests__/actor-entity.test.ts | 38 +++++++++++++----- graphql/server/src/middleware/actor-entity.ts | 28 +++++++++---- graphql/server/src/middleware/auth.ts | 39 +++++++++++++------ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/graphql/server/src/middleware/__tests__/actor-entity.test.ts b/graphql/server/src/middleware/__tests__/actor-entity.test.ts index 162325f7d1..3b3c14a44a 100644 --- a/graphql/server/src/middleware/__tests__/actor-entity.test.ts +++ b/graphql/server/src/middleware/__tests__/actor-entity.test.ts @@ -78,11 +78,19 @@ describe('authenticate middleware entity attribution', () => { }; const PROBE = /to_regprocedure\('app_scope\.actor_entity/; - const withAppScope = (present: boolean, row?: { entity_id: string; entity_type: string }) => - mockQuery.mockImplementation(async (sql: string) => - PROBE.test(sql) ? { rows: [{ present }] } : { rows: row ? [row] : [] } - ); - const entityCalls = () => mockQuery.mock.calls.filter(([sql]) => !PROBE.test(sql)); + const USERS_MODULE = /FROM metaschema_modules_public\.users_module WHERE database_id = \$1/; + /** `present` — app_scope is deployed; `users` — this database installs a users module. */ + const withEntityModel = ( + { present, users = present }: { present: boolean; users?: boolean }, + row?: { entity_id: string; entity_type: string } + ) => + mockQuery.mockImplementation(async (sql: string) => { + if (PROBE.test(sql)) return { rows: [{ present }] }; + if (USERS_MODULE.test(sql)) return { rows: [{ installed: users }] }; + return { rows: row ? [row] : [] }; + }); + const entityCalls = () => + mockQuery.mock.calls.filter(([sql]) => !PROBE.test(sql) && !USERS_MODULE.test(sql)); beforeEach(() => { mockQuery.mockReset(); @@ -91,7 +99,7 @@ describe('authenticate middleware entity attribution', () => { it('stamps the actor entity resolved through app_scope.actor_entity for a user token', async () => { mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1', role: 'authenticated' }] }); - withAppScope(true, { entity_id: 'user-1', entity_type: 'app' }); + withEntityModel({ present: true }, { entity_id: 'user-1', entity_type: 'app' }); const { req, next } = await run('Bearer tok'); @@ -107,7 +115,7 @@ describe('authenticate middleware entity attribution', () => { rowCount: 1, rows: [{ user_id: 'principal-1', principal_id: 'principal-1', kind: 'principal' }] }); - withAppScope(true, { entity_id: 'org-1', entity_type: 'org' }); + withEntityModel({ present: true }, { entity_id: 'org-1', entity_type: 'org' }); const { req } = await run('Bearer tok'); @@ -117,7 +125,7 @@ describe('authenticate middleware entity attribution', () => { it('fails the request instead of continuing entityless when the actor cannot be resolved', async () => { mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] }); - withAppScope(true); + withEntityModel({ present: true }); const { req, res, next } = await run('Bearer tok'); @@ -132,10 +140,22 @@ describe('authenticate middleware entity attribution', () => { it('stamps nothing when the tenant database has no app_scope', async () => { mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] }); - withAppScope(false); + withEntityModel({ present: false }); + + const { req, next } = await run('Bearer tok'); + + expect(entityCalls()).toEqual([]); + expect(req.actorEntity).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + + it('stamps nothing when app_scope is deployed but this database installs no users module', async () => { + mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] }); + withEntityModel({ present: true, users: false }); const { req, next } = await run('Bearer tok'); + expect(mockQuery.mock.calls.some(([sql, params]) => USERS_MODULE.test(sql) && params[0] === 'db-1')).toBe(true); expect(entityCalls()).toEqual([]); expect(req.actorEntity).toBeUndefined(); expect(next).toHaveBeenCalled(); diff --git a/graphql/server/src/middleware/actor-entity.ts b/graphql/server/src/middleware/actor-entity.ts index 53238bcf50..3dc4d8af8f 100644 --- a/graphql/server/src/middleware/actor-entity.ts +++ b/graphql/server/src/middleware/actor-entity.ts @@ -8,10 +8,12 @@ * maps a principal credential to its owner — a principal is never an entity * of its own. Callers cannot supply the pair on the public surface. * - * `app_scope` ships with the Constructive platform modules; a tenant database - * without it (a bare RLS module) has no entity model, so no pair is stamped. - * Once the resolver is present, a session that cannot be attributed is a - * hard failure — work is never created entityless. + * A database has an entity model only when it installs the users module + * (`app_scope.actor_entity` types an actor by its users row). A database + * without one — a bare RLS module, an auth-only fixture — has no entity for + * an actor to carry, so no pair is stamped. Once the model is present, a + * session that cannot be attributed is a hard failure — work is never + * created entityless. */ import type { Pool } from 'pg'; @@ -61,12 +63,22 @@ export const createActorEntityResolver = (opts: ActorEntityResolverOptions) => { }; }; -/** Whether the tenant database exposes `app_scope.actor_entity`. */ -export const hasActorEntityResolver = async (pool: Pool): Promise => { +/** + * Whether `databaseId` has an entity model to attribute against: the + * `app_scope.actor_entity` resolver is deployed and the database installs a + * users module for it to read. + */ +export const hasEntityModel = async (pool: Pool, databaseId: string): Promise => { const result = await pool.query<{ present: boolean }>( - "SELECT to_regprocedure('app_scope.actor_entity(uuid, uuid)') IS NOT NULL AS present" + `SELECT to_regprocedure('app_scope.actor_entity(uuid, uuid)') IS NOT NULL + AND to_regclass('metaschema_modules_public.users_module') IS NOT NULL AS present` + ); + if (result.rows[0]?.present !== true) return false; + const installed = await pool.query<{ installed: boolean }>( + 'SELECT EXISTS (SELECT 1 FROM metaschema_modules_public.users_module WHERE database_id = $1) AS installed', + [databaseId] ); - return result.rows[0]?.present === true; + return installed.rows[0]?.installed === true; }; export const pgActorEntityQuery = (pool: Pool): ActorEntityQuery => diff --git a/graphql/server/src/middleware/auth.ts b/graphql/server/src/middleware/auth.ts index 5ee73ecc41..9a006aab23 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -9,7 +9,7 @@ import { getPgPool } from 'pg-cache'; import pgQueryContext from 'pg-query-context'; import { respondWithGraphQLError } from '../errors/graphql-response'; -import { createActorEntityResolver, hasActorEntityResolver, pgActorEntityQuery } from './actor-entity'; +import { createActorEntityResolver, hasEntityModel, pgActorEntityQuery } from './actor-entity'; const log = new Logger('auth'); const isDev = () => getNodeEnv() === 'development'; @@ -34,17 +34,32 @@ const parseCookieToken = (req: Request, cookieName: string): string | undefined export const createAuthenticateMiddleware = ( opts: PgpmOptions ): RequestHandler => { + type Pool = Parameters[0]; type Resolver = ReturnType | null; - const resolvers = new Map>(); - const actorEntityResolver = (dbname: string, pool: Parameters[0]): Promise => { - let resolver = resolvers.get(dbname); - if (!resolver) { - resolver = hasActorEntityResolver(pool).then((present) => - present ? createActorEntityResolver({ query: pgActorEntityQuery(pool) }) : null - ); - resolver.catch(() => resolvers.delete(dbname)); - resolvers.set(dbname, resolver); - } + // Whether a database has an entity model is re-probed on a TTL: a users + // module deployed while the server runs must start attributing without a + // restart, and a negative answer is never sticky. + const PROBE_TTL_MS = 60_000; + const resolvers = new Map>(); + const probes = new Map; probedAt: number }>(); + const actorEntityResolver = (dbname: string, databaseId: string, pool: Pool): Promise => { + const key = `${dbname}:${databaseId}`; + const hit = probes.get(key); + if (hit && hit.probedAt + PROBE_TTL_MS > Date.now()) return hit.resolver; + const resolver = hasEntityModel(pool, databaseId).then((present) => { + if (!present) { + log.debug(`[auth] database ${databaseId} installs no entity model; no entity attribution stamped`); + return null; + } + let cached = resolvers.get(dbname); + if (!cached) { + cached = createActorEntityResolver({ query: pgActorEntityQuery(pool) }); + resolvers.set(dbname, cached); + } + return cached; + }); + resolver.catch(() => probes.delete(key)); + probes.set(key, { resolver, probedAt: Date.now() }); return resolver; }; @@ -139,7 +154,7 @@ export const createAuthenticateMiddleware = ( log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`); if (token?.user_id && api.databaseId) { - const resolve = await actorEntityResolver(api.dbname, pool); + const resolve = await actorEntityResolver(api.dbname, api.databaseId, pool); if (resolve) { req.actorEntity = await resolve(api.databaseId, token.user_id); }