From 6da982ef4fc3c85fa95cf0a34f9525f158a8d641 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 21:14:35 +0800 Subject: [PATCH] feat: add tenant SSO context surface --- .../src/middleware/__tests__/routing.test.ts | 2 + graphql/server/src/middleware/graphile.ts | 5 ++ graphql/server/src/middleware/routing.ts | 3 + packages/express-context/README.md | 18 +++++ .../__tests__/loaders/auth-loaders.test.ts | 32 ++++++-- .../__tests__/loaders/sso-surface.test.ts | 74 +++++++++++++++++++ .../__tests__/pg-settings.test.ts | 16 +++- packages/express-context/src/context.ts | 3 +- packages/express-context/src/index.ts | 2 + .../src/loaders/identity-providers.ts | 47 +++++++----- packages/express-context/src/loaders/index.ts | 2 + .../src/loaders/sso-surface.ts | 45 +++++++++++ packages/express-context/src/pg-settings.ts | 7 ++ packages/express-context/src/types.ts | 10 +++ 14 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 packages/express-context/__tests__/loaders/sso-surface.test.ts create mode 100644 packages/express-context/src/loaders/sso-surface.ts diff --git a/graphql/server/src/middleware/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index bcd013a888..5d8fb008b1 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -44,6 +44,7 @@ const matchedRoute = (overrides: Partial = {}): ResolvedRoute => verification_status: 'verified', tls_status: 'ready', tls_secret_name: 'tls-api-example-com', + runtime_site_id: 'site-1', ...overrides }); @@ -104,6 +105,7 @@ describe('routeToApiStructure', () => { expect(structure).toEqual( expect.objectContaining({ apiId: 'api-1', + siteId: 'site-1', databaseId: 'db-1', dbname: 'tenant_db', roleName: 'api_role', diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..e09e5189f0 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -224,6 +224,11 @@ const buildPreset = ( if (req.api?.apiId) { context['jwt.claims.api_id'] = req.api.apiId; } + // Independent trusted Site identity from scoped routing. A Site is + // not inferred from api_id because multiple Sites may share one API. + if (req.api?.siteId) { + context['jwt.claims.site_id'] = req.api.siteId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } diff --git a/graphql/server/src/middleware/routing.ts b/graphql/server/src/middleware/routing.ts index 752b32bdff..10bf6da955 100644 --- a/graphql/server/src/middleware/routing.ts +++ b/graphql/server/src/middleware/routing.ts @@ -37,6 +37,8 @@ export interface ResolvedRoute { verification_status: string | null; tls_status: string | null; tls_secret_name: string | null; + /** Optional Site security context bound to this route independently of API. */ + runtime_site_id: string | null; } const RESOLVER_FUNCTION = 'resolve_route'; @@ -128,6 +130,7 @@ export const routeToApiStructure = ( return { apiId: config.api_id ?? route.target_source_id ?? undefined, + siteId: route.runtime_site_id ?? undefined, // Scoped APIs leave dbname NULL when their schemas live in the serving // database; fall back to the server's own database in that case. dbname: config.dbname || opts.pg?.database || '', diff --git a/packages/express-context/README.md b/packages/express-context/README.md index 72534d75cf..4927abda36 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -70,6 +70,24 @@ Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache | `webauthnLoader` | `routing_public.webauthn_settings` | WebAuthn/passkey configuration | | `authSettingsLoader` | `metaschema_modules_public.sessions_module` | Cookie/captcha settings (two-step tenant DB discovery) | +### Opt-in authentication loaders + +`identityProvidersLoader` resolves enabled Tenant Provider configuration and +secrets. `ssoSurfaceLoader` resolves only the current database's provisioned +unified-auth private schema. Both are intentionally excluded from +`createDefaultRegistry()` and must be registered by the authentication service +that owns their cost and secret boundary: + +```typescript +const registry = createDefaultRegistry(); +registry.register(identityProvidersLoader); +registry.register(ssoSurfaceLoader); +``` + +`ssoSurfaceLoader` returns `undefined` when the current Tenant has no provisioned +unified-auth module. It never guesses a global `sso_private` schema or searches +another database. + ### Custom loaders ```typescript diff --git a/packages/express-context/__tests__/loaders/auth-loaders.test.ts b/packages/express-context/__tests__/loaders/auth-loaders.test.ts index f09e8e31fe..d23d0772e7 100644 --- a/packages/express-context/__tests__/loaders/auth-loaders.test.ts +++ b/packages/express-context/__tests__/loaders/auth-loaders.test.ts @@ -141,8 +141,22 @@ describe('identityProvidersLoader', () => { }; const provisioned = (rows: unknown[]) => [ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, - { rows: [{ schema_name: 'tenant_a_secrets', table_name: 'internal_secrets' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, + { + rows: [{ + schema_name: 'tenant_a_secrets', + table_name: 'internal_secrets', + scope: 'database', + prefix: '' + }] + }, { rows } ]; @@ -152,9 +166,10 @@ describe('identityProvidersLoader', () => { const module = await identityProvidersLoader.resolve(ctx(pool, 'db-a')); expect(calls[0].values).toEqual(['db-a']); - expect(calls[1].values).toEqual(['db-a']); - expect(calls[2].text).toContain('"tenant_a_secrets"."internal_secrets_get"'); + expect(calls[1].values).toEqual(['db-a', 'database']); + expect(calls[2].text).toContain('"tenant_a_secrets"."_internal_secrets_get"'); expect(calls[2].text).toContain('"tenant_a_auth_private"."identity_providers"'); + expect(calls[2].values).toEqual(['db-a']); expect(module?.providers.google).toMatchObject({ clientId: 'client-abc', clientSecret: 'shh', @@ -170,7 +185,14 @@ describe('identityProvidersLoader', () => { it('fails when the secret store is absent instead of yielding a secretless client', async () => { const { pool } = fakePool([ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, { rows: [] } ]); await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow( diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts new file mode 100644 index 0000000000..92af9c3e39 --- /dev/null +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -0,0 +1,74 @@ +import type { Pool } from 'pg'; + +import { createDefaultRegistry } from '../../src/loaders'; +import { createLoaderRegistry } from '../../src/loaders/registry'; +import { ssoSurfaceLoader } from '../../src/loaders/sso-surface'; +import type { LoaderContext } from '../../src/loaders/types'; +import type { SsoSurface } from '../../src/types'; + +interface Call { + text: string; + values?: unknown[]; +} + +const fakePool = (rows: unknown[]) => { + const calls: Call[] = []; + const pool = { + query: jest.fn(async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + return { rows }; + }) + } as unknown as Pool; + return { calls, pool }; +}; + +const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({ + routingPool: {} as Pool, + tenantPool, + databaseId, + dbname: 'tenant' +}); + +beforeEach(() => ssoSurfaceLoader.invalidate()); + +describe('ssoSurfaceLoader', () => { + it('resolves the database-scoped private schema from authoritative metadata', async () => { + const { calls, pool } = fakePool([ + { private_schema: 'tenant_a_sso_private' } + ]); + + const surface: SsoSurface | undefined = await ssoSurfaceLoader.resolve( + ctx(pool, 'db-a') + ); + + expect(surface).toEqual({ privateSchema: 'tenant_a_sso_private' }); + expect(calls).toHaveLength(1); + expect(calls[0].values).toEqual(['db-a']); + expect(calls[0].text).toMatch(/unified_auth\.database_id = \$1/); + expect(calls[0].text).toMatch(/unified_auth\.scope = 'database'/); + expect(calls[0].text).toMatch( + /private_schema\.id = unified_auth\.private_schema_id/ + ); + }); + + it('returns undefined when this Tenant has no provisioned module', async () => { + const { pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('does not run an unkeyed lookup without a database ID', async () => { + const { calls, pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool, ''))).rejects.toThrow( + /no databaseId/ + ); + expect(calls).toHaveLength(0); + }); + + it('is typed but remains explicitly opt-in', async () => { + expect(createDefaultRegistry().has('ssoSurface')).toBe(false); + + const registry = createLoaderRegistry(); + registry.register(ssoSurfaceLoader); + expect(registry.has('ssoSurface')).toBe(true); + }); +}); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 6b87b83b15..590486d26f 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -3,6 +3,7 @@ import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; const api: ApiStructure = { apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + siteId: '87763e7e-8aeb-4e5c-98ce-95e16b6f62ac', dbname: 'testdb', anonRole: 'anonymous', roleName: 'authenticated', @@ -17,6 +18,7 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); expect(settings['role']).toBe('anonymous'); }); @@ -42,10 +44,22 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { it('is derived only from the resolved api, never from the token', () => { const token = { user_id: 'u1', - api_id: 'attacker-controlled' + api_id: 'attacker-controlled', + site_id: 'attacker-controlled' } as unknown as ConstructiveAPIToken; const settings = buildPgSettings({ api, token, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); + }); + + it('omits jwt.claims.site_id when the route has no Site context', () => { + const settings = buildPgSettings({ + api: { ...api, siteId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.site_id']).toBeUndefined(); }); }); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..14eb8ad90f 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -7,7 +7,7 @@ * - pgSettings (role, claims, request_id, database_id) * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) - * - Convenience fields (userId, databaseId, requestId) + * - Convenience fields (userId, databaseId, siteId, requestId) * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream @@ -113,6 +113,7 @@ export function buildContext( token, pgSettings, databaseId: api.databaseId ?? null, + siteId: api.siteId ?? null, userId: token?.user_id ?? null, requestId, pool: tenantPool, diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 013e195f5d..5fe55920a4 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -56,6 +56,7 @@ export type { LlmConfig, PubkeyChallengeSettings, RlsModule, + SsoSurface, WebauthnSettings, WithPgClient, } from './types'; @@ -103,6 +104,7 @@ export { requireDatabaseId, requireIdentityProvider, rlsLoader, + ssoSurfaceLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 837a8292e3..ac44404280 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -28,7 +28,8 @@ import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.table_name AS table_name + SELECT s.schema_name AS schema_name, m.table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.identity_providers_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id WHERE m.database_id = $1 @@ -36,29 +37,37 @@ const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` `; const INTERNAL_SECRETS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name + SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.internal_secrets_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id - WHERE m.database_id = $1 + WHERE m.database_id = $1 AND m.scope = $2 LIMIT 1 `; interface DiscoveredLocation { schema_name: string; table_name: string; + scope: string; + prefix: string; } /** * The providers query, with the tenant's own secret getter inlined. * - * The getter is `_get(name, namespace_id)` in the - * discovered store schema — the same function the auth procedures use, so a - * secret rotated through the platform's rotate verb is picked up with no - * further coordination. A provider whose `client_secret_id` is set but whose - * secret does not resolve yields `clientSecret: null`, which the caller must - * treat as a configuration fault rather than as a public client. + * The getter is the generated internal-secrets getter in the discovered store + * schema — the same function the auth procedures use, so a secret rotated + * through the platform's rotate verb is picked up with no further + * coordination. Database-scoped stores take the current database ID as their + * first argument; app/platform stores do not. A provider whose + * `client_secret_id` is set but whose secret does not resolve yields + * `clientSecret: null`, which the caller must treat as a configuration fault + * rather than as a public client. */ -const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredLocation) => ` +const buildProvidersQuery = ( + providers: DiscoveredLocation, + secrets: DiscoveredLocation +) => ` SELECT p.id, p.slug, @@ -68,7 +77,8 @@ const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredL p.client_id, CASE WHEN p.client_secret_id IS NULL THEN NULL - ELSE "${secrets.schema_name}"."${secrets.table_name}_get"( + ELSE "${secrets.schema_name}"."${secrets.prefix}_internal_secrets_get"( + ${secrets.scope === 'database' ? '$1,' : ''} p.slug || '/client-secret', uuid_nil() ) @@ -155,16 +165,16 @@ const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => { const discoverOne = async ( ctx: LoaderContext, sql: string, - moduleName: string + values: unknown[] ): Promise => { - const result = await ctx.tenantPool.query(sql, [ctx.databaseId]); + const result = await ctx.tenantPool.query(sql, values); const row = result.rows[0]; if (!row?.schema_name || !row?.table_name) { // Not provisioned for this tenant — the loader contract's undefined. The // module name is kept in the debug trail rather than guessed at by callers. return undefined; } - return { schema_name: row.schema_name, table_name: row.table_name }; + return row; }; // ─── Loader ───────────────────────────────────────────────────────────────── @@ -184,14 +194,14 @@ export const identityProvidersLoader: ModuleLoader = const providers = await discoverOne( ctx, IDENTITY_PROVIDERS_DISCOVERY_SQL, - 'identity_providers_module' + [databaseId] ); if (!providers) return undefined; const secrets = await discoverOne( ctx, INTERNAL_SECRETS_DISCOVERY_SQL, - 'internal_secrets_module' + [databaseId, providers.scope] ); // A provider table without its secret store cannot yield a usable client // secret, and silently returning secret-less providers would present a @@ -203,7 +213,10 @@ export const identityProvidersLoader: ModuleLoader = ); } - const result = await tenantPool.query(buildProvidersQuery(providers, secrets)); + const result = await tenantPool.query( + buildProvidersQuery(providers, secrets), + secrets.scope === 'database' ? [databaseId] : [] + ); const bySlug: Record = {}; for (const row of result.rows) { diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a8a3203428..fc7081e870 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -16,6 +16,7 @@ * * Opt-in (not in the default registry, register it explicitly): * - identityProviders (three round trips, decrypts client secrets) + * - ssoSurface (current Tenant's provisioned unified-auth private schema) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -55,6 +56,7 @@ export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { ssoSurfaceLoader } from './sso-surface'; export { webauthnLoader } from './webauthn'; /** diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts new file mode 100644 index 0000000000..212319214d --- /dev/null +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -0,0 +1,45 @@ +/** + * Unified-auth SSO Surface Loader (Tier 2 — tenant DB) + * + * Resolves only the private schema provisioned for the current database's + * database-scoped unified_auth_module. Procedure names are fixed by the DB + * module contract; policy, Site configuration, and Provider secrets remain in + * their owning loaders/functions. + * + * This loader is opt-in and is not registered by createDefaultRegistry(). + */ + +import type { SsoSurface } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +const SSO_SURFACE_SQL = ` + SELECT private_schema.name AS private_schema + FROM metaschema_modules_public.unified_auth_module unified_auth + JOIN metaschema_public.schema private_schema + ON private_schema.id = unified_auth.private_schema_id + WHERE unified_auth.database_id = $1 + AND unified_auth.scope = 'database' + LIMIT 1 +`; + +interface SsoSurfaceRow { + private_schema: string; +} + +export const ssoSurfaceLoader: ModuleLoader = + createModuleLoader({ + name: 'ssoSurface', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'ssoSurface'); + + const result = await tenantPool.query(SSO_SURFACE_SQL, [ + databaseId + ]); + const row = result.rows[0]; + return row ? { privateSchema: row.private_schema } : undefined; + } + }); diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index cb86336456..65db1bf78b 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -64,6 +64,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.api_id'] = api.apiId; } + // Site provenance is an independent trusted routing fact. Multiple Sites + // may share an API, so it must never be reconstructed from api_id, Origin, + // Referer, or token claims. + if (api.siteId) { + settings['jwt.claims.site_id'] = api.siteId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 4316018209..3da4434865 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -98,6 +98,11 @@ export interface AuthSurface { connectedAccountsView: string; } +/** Current Tenant's provisioned private unified-auth module surface. */ +export interface SsoSurface { + privateSchema: string; +} + /** One identity provider row, with its client secret resolved. */ export interface IdentityProviderConfig { id: string; @@ -136,6 +141,8 @@ export interface IdentityProvidersModule { export interface ApiStructure { apiId?: string; + /** Trusted Site runtime identity emitted by scoped routing, when present. */ + siteId?: string; dbname: string; anonRole: string; roleName: string; @@ -241,6 +248,7 @@ export interface BuiltinModuleMap { databaseSettings: DatabaseSettings; authSettings: AuthSettings; authSurface: AuthSurface; + ssoSurface: SsoSurface; identityProviders: IdentityProvidersModule; pubkeyChallengeSettings: PubkeyChallengeSettings; webauthnSettings: WebauthnSettings; @@ -274,6 +282,8 @@ export interface ConstructiveContext { pgSettings: Record; /** Database UUID from the API resolver */ databaseId: string | null; + /** Trusted Site UUID from the resolved route; never inferred from Origin. */ + siteId: string | null; /** Authenticated user ID from the JWT token */ userId: string | null; /** Per-request correlation ID for distributed tracing */