diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e..c688dae57 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -158,6 +158,14 @@ describe('getEnvOptions', () => { }); }); + it('parses the API entity type environment variable', () => { + const result = getGraphQLEnvVars({ + API_ENTITY_TYPE: 'platform' + }); + + expect(result.api?.entityType).toBe('platform'); + }); + it('accepts custom SMS provider names', () => { const result = getGraphQLEnvVars({ SMS_PROVIDER: 'custom-sms-gateway' diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef2..1e7962ac2 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -18,6 +18,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial API_META_SCHEMAS, API_ANON_ROLE, API_ROLE_NAME, + API_ENTITY_TYPE, EMBEDDER_PROVIDER, EMBEDDER_MODEL, @@ -65,7 +66,8 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }), ...(API_META_SCHEMAS && { metaSchemas: API_META_SCHEMAS.split(',').map(s => s.trim()) }), ...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }), - ...(API_ROLE_NAME && { roleName: API_ROLE_NAME }) + ...(API_ROLE_NAME && { roleName: API_ROLE_NAME }), + ...(API_ENTITY_TYPE && { entityType: API_ENTITY_TYPE }) }, ...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && { llm: { diff --git a/graphql/server/src/middleware/__tests__/graphile-entity-attribution.test.ts b/graphql/server/src/middleware/__tests__/graphile-entity-attribution.test.ts new file mode 100644 index 000000000..34d35a392 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/graphile-entity-attribution.test.ts @@ -0,0 +1,39 @@ +import type { Request } from 'express'; + +describe('graphile context entity attribution', () => { + const buildContext = ( + req: Partial, + entityType?: string + ): Record => { + const context: Record = {}; + + if (entityType && req.databaseId) { + context['jwt.claims.entity_id'] = req.databaseId; + context['jwt.claims.entity_type'] = entityType; + } + + if (req.databaseId) { + context['jwt.claims.database_id'] = req.databaseId; + } + + return context; + }; + + it('sets the complete entity pair when configured and the request has a database', () => { + expect(buildContext({ databaseId: 'db-1' }, 'platform')).toEqual({ + 'jwt.claims.database_id': 'db-1', + 'jwt.claims.entity_id': 'db-1', + 'jwt.claims.entity_type': 'platform' + }); + }); + + it('sets no entity claims when the API entity type is not configured', () => { + expect(buildContext({ databaseId: 'db-1' })).toEqual({ + 'jwt.claims.database_id': 'db-1' + }); + }); + + it('sets no entity claims when the request has no database', () => { + expect(buildContext({}, 'platform')).toEqual({}); + }); +}); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7a..34de3d5c5 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -167,7 +167,8 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig + compute?: ComputeConfig, + apiEntityType?: string ): GraphileConfig.Preset => { return { extends: [createConstructivePreset(databaseSettings)], @@ -214,6 +215,10 @@ const buildPreset = ( const context: Record = {}; if (req) { + if (apiEntityType && req.databaseId) { + context['jwt.claims.entity_id'] = req.databaseId; + context['jwt.claims.entity_type'] = apiEntityType; + } if (req.databaseId) { context['jwt.claims.database_id'] = req.databaseId; } @@ -403,7 +408,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute, + opts.api?.entityType + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 72fff4c73..98ae7a522 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -30,6 +30,8 @@ export interface GraphileFeatureOptions { export interface ApiOptions { /** Database schemas to expose through the API */ exposedSchemas?: string[]; + /** Entity type attributed to requests received through this API */ + entityType?: string; /** Anonymous role name for unauthenticated requests */ anonRole?: string; /** Default role name for authenticated requests */