Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions graphql/server/src/middleware/__tests__/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const matchedRoute = (overrides: Partial<ResolvedRoute> = {}): ResolvedRoute =>
verification_status: 'verified',
tls_status: 'ready',
tls_secret_name: 'tls-api-example-com',
runtime_site_id: 'site-1',
...overrides
});

Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions graphql/server/src/middleware/graphile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions graphql/server/src/middleware/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || '',
Expand Down
18 changes: 18 additions & 0 deletions packages/express-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions packages/express-context/__tests__/loaders/auth-loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
];

Expand All @@ -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',
Expand All @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions packages/express-context/__tests__/loaders/sso-surface.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 15 additions & 1 deletion packages/express-context/__tests__/pg-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');
});

Expand All @@ -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();
});
});
3 changes: 2 additions & 1 deletion packages/express-context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/express-context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type {
LlmConfig,
PubkeyChallengeSettings,
RlsModule,
SsoSurface,
WebauthnSettings,
WithPgClient,
} from './types';
Expand Down Expand Up @@ -103,6 +104,7 @@ export {
requireDatabaseId,
requireIdentityProvider,
rlsLoader,
ssoSurfaceLoader,
webauthnLoader,
} from './loaders';

Expand Down
47 changes: 30 additions & 17 deletions packages/express-context/src/loaders/identity-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,37 +28,46 @@ 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
LIMIT 1
`;

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 `<internal_secrets_table_name>_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,
Expand All @@ -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()
)
Expand Down Expand Up @@ -155,16 +165,16 @@ const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => {
const discoverOne = async (
ctx: LoaderContext,
sql: string,
moduleName: string
values: unknown[]
): Promise<DiscoveredLocation | undefined> => {
const result = await ctx.tenantPool.query<DiscoveredLocation>(sql, [ctx.databaseId]);
const result = await ctx.tenantPool.query<DiscoveredLocation>(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 ─────────────────────────────────────────────────────────────────
Expand All @@ -184,14 +194,14 @@ export const identityProvidersLoader: ModuleLoader<IdentityProvidersModule> =
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
Expand All @@ -203,7 +213,10 @@ export const identityProvidersLoader: ModuleLoader<IdentityProvidersModule> =
);
}

const result = await tenantPool.query<ProviderRow>(buildProvidersQuery(providers, secrets));
const result = await tenantPool.query<ProviderRow>(
buildProvidersQuery(providers, secrets),
secrets.scope === 'database' ? [databaseId] : []
);

const bySlug: Record<string, IdentityProviderConfig> = {};
for (const row of result.rows) {
Expand Down
Loading