Skip to content
Merged
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
207 changes: 207 additions & 0 deletions packages/express-context/__tests__/loaders/auth-loaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { Pool } from 'pg';

import { authSettingsLoader } from '../../src/loaders/auth-settings';
import { authSurfaceLoader } from '../../src/loaders/auth-surface';
import {
identityProvidersLoader,
requireIdentityProvider
} from '../../src/loaders/identity-providers';
import type { LoaderContext } from '../../src/loaders/types';
import type { IdentityProvidersModule } from '../../src/types';

interface Call {
text: string;
values?: unknown[];
}

/**
* A pool that answers each query in turn and records what it was asked. The
* loaders' whole job is issuing the right SQL with the right binding, so the
* calls are the assertion target.
*/
const fakePool = (responses: Array<{ rows: unknown[] }>) => {
const calls: Call[] = [];
let i = 0;
const pool = {
query: jest.fn(async (text: string, values?: unknown[]) => {
calls.push({ text, values });
const next = responses[i++];
if (!next) throw new Error(`unexpected query #${i}: ${text}`);
return next;
})
} as unknown as Pool;
return { pool, calls };
};

const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({
routingPool: {} as Pool,
tenantPool,
databaseId,
dbname: 'tenant'
});

beforeEach(() => {
authSettingsLoader.invalidate();
authSurfaceLoader.invalidate();
identityProvidersLoader.invalidate();
});

describe('authSettingsLoader', () => {
it('discovers the settings table for the context database only', async () => {
const { pool, calls } = fakePool([
{ rows: [{ schema_name: 'tenant_a_auth', table_name: 'auth_settings' }] },
{ rows: [{ cookie_secure: true, cookie_samesite: 'lax', cookie_path: '/' }] }
]);

const settings = await authSettingsLoader.resolve(ctx(pool, 'db-a'));

expect(calls[0].values).toEqual(['db-a']);
expect(calls[0].text).toMatch(/WHERE sm\.database_id = \$1/);
// Step 2 reads out of the schema step 1 resolved — which is precisely why
// step 1 being keyed matters.
expect(calls[1].text).toContain('"tenant_a_auth"."auth_settings"');
expect(settings).toMatchObject({ cookieSecure: true, cookieSamesite: 'lax' });
});

it('is undefined when the tenant provisions no sessions module', async () => {
const { pool } = fakePool([{ rows: [] }]);
await expect(authSettingsLoader.resolve(ctx(pool))).resolves.toBeUndefined();
});

it('refuses a context with no databaseId rather than running unkeyed', async () => {
const { pool, calls } = fakePool([]);
await expect(authSettingsLoader.resolve(ctx(pool, ''))).rejects.toThrow(/no databaseId/);
expect(calls).toHaveLength(0);
});
});

describe('authSurfaceLoader', () => {
it('resolves the tenant auth schemas in one keyed round trip', async () => {
const { pool, calls } = fakePool([
{
rows: [
{
private_schema: 'tenant_a_auth_private',
public_schema: 'tenant_a_auth_public',
identifiers_public_schema: 'tenant_a_identifiers',
emails_table: 'emails',
connected_accounts_view: 'user_connected_accounts'
}
]
}
]);

const surface = await authSurfaceLoader.resolve(ctx(pool, 'db-a'));

expect(calls).toHaveLength(1);
expect(calls[0].values).toEqual(['db-a']);
expect(surface).toEqual({
privateSchema: 'tenant_a_auth_private',
publicSchema: 'tenant_a_auth_public',
identifiersPublicSchema: 'tenant_a_identifiers',
emailsTable: 'emails',
connectedAccountsView: 'user_connected_accounts'
});
});

it('joins the companion modules within the same tenant', async () => {
const { pool, calls } = fakePool([{ rows: [] }]);
await authSurfaceLoader.resolve(ctx(pool));
// A join on schema id alone would pair one tenant's providers with
// another's connected accounts and still return a plausible row.
expect(calls[0].text).toMatch(/connected\.database_id = providers\.database_id/);
expect(calls[0].text).toMatch(/emails\.database_id = providers\.database_id/);
});
});

describe('identityProvidersLoader', () => {
const providerRow: Record<string, unknown> = {
id: 'p1',
slug: 'google',
kind: 'oidc',
display_name: 'Google',
enabled: true,
client_id: 'client-abc',
client_secret: 'shh',
authorization_url: null,
token_url: null,
userinfo_url: null,
issuer_url: 'https://accounts.google.com',
discovery_url_override: null,
discovery_doc: null,
jwks: null,
jwks_fetched_at: null,
acceptable_client_ids: null,
scopes: ['openid', 'email'],
extra_authorization_params: null,
email_optional: null,
allow_link_by_email: null,
skip_nonce_check: null,
pkce_enabled: null
};

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 }
];

it('keys both discovery steps and inlines the tenant secret getter', async () => {
const { pool, calls } = fakePool(provisioned([providerRow]));

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[2].text).toContain('"tenant_a_auth_private"."identity_providers"');
expect(module?.providers.google).toMatchObject({
clientId: 'client-abc',
clientSecret: 'shh',
issuerUrl: 'https://accounts.google.com'
});
});

it('defaults an unset nonce/PKCE policy to the safe side', async () => {
const { pool } = fakePool(provisioned([providerRow]));
const module = await identityProvidersLoader.resolve(ctx(pool));
expect(module?.providers.google).toMatchObject({ skipNonceCheck: false, pkceEnabled: true });
});

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: [] }
]);
await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow(
/internal_secrets_module/
);
});

it('is undefined when the tenant provisions no providers module', async () => {
const { pool } = fakePool([{ rows: [] }]);
await expect(identityProvidersLoader.resolve(ctx(pool))).resolves.toBeUndefined();
});

it('rejects a provider row with no client_id', async () => {
const { pool } = fakePool(provisioned([{ ...providerRow, client_id: null }]));
await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow(/client_id is not set/);
});
});

describe('requireIdentityProvider', () => {
const module = (enabled: boolean): IdentityProvidersModule => ({
providers: { google: { slug: 'google', enabled } as IdentityProvidersModule['providers'][string] },
source: { schemaName: 's', tableName: 't' }
});

it('returns the provider when configured and enabled', () => {
expect(requireIdentityProvider(module(true), 'google').slug).toBe('google');
});

it('distinguishes unprovisioned, unknown and disabled', () => {
expect(() => requireIdentityProvider(undefined, 'google')).toThrow(/not provisioned/);
expect(() => requireIdentityProvider(module(true), 'okta')).toThrow(/not configured/);
expect(() => requireIdentityProvider(module(false), 'google')).toThrow(/disabled/);
});
});
61 changes: 61 additions & 0 deletions packages/express-context/__tests__/loaders/tenant-keying.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* The invariant behind constructive-planning#1403: every discovery query a
* loader runs against a *tenant* database must be keyed by that tenant.
*
* One serving database holds several tenants' schemas in the normal
* schema-per-tenant topology, so an unkeyed `metaschema_modules_public` lookup
* does not fail — it returns a neighbouring tenant's row, and the loader cache
* then serves that wrong answer for its whole TTL. That is a cross-tenant
* config read with no symptom, which is why it is asserted structurally here
* rather than left to a test of any one loader.
*/

import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';

const LOADERS_DIR = join(__dirname, '..', '..', 'src', 'loaders');

/** Loader sources — the plumbing files carry no SQL. */
const INFRASTRUCTURE = new Set(['create-loader.ts', 'index.ts', 'registry.ts', 'types.ts']);

const loaderSources = readdirSync(LOADERS_DIR)
.filter(f => f.endsWith('.ts') && !INFRASTRUCTURE.has(f))
.map(file => ({ file, src: readFileSync(join(LOADERS_DIR, file), 'utf8') }));

/**
* Every `SELECT ... FROM metaschema_modules_public.<module> ...` in the source,
* sliced from FROM to the end of the template literal it lives in.
*/
const moduleQueries = ({ file, src }: { file: string; src: string }) => {
const matches = [...src.matchAll(/FROM\s+metaschema_modules_public\.(\w+)([\s\S]*?)`/g)];
return matches.map(m => ({ file, module: m[1], body: m[2] }));
};

describe('tenant-DB discovery is keyed by database_id', () => {
it('finds the module discovery queries it is meant to be checking', () => {
// A regex that silently matches nothing would make every assertion below
// vacuously true — including for a loader added later.
const all = loaderSources.flatMap(moduleQueries);
expect(all.length).toBeGreaterThanOrEqual(6);
expect(new Set(all.map(q => q.module))).toContain('sessions_module');
});

it.each(loaderSources.flatMap(moduleQueries))(
'$file: $module is filtered by database_id',
({ body }) => {
expect(body).toMatch(/WHERE[\s\S]*\bdatabase_id\s*=\s*\$1/);
}
);

it('binds a parameter to every parameterised discovery query', () => {
for (const { file, src } of loaderSources) {
if (!/\$1/.test(src)) continue;
// `$1` in the SQL with no second argument at the call site is a runtime
// error, not a type error — pg accepts `query(text)` happily.
expect({ file, passesParams: /query<[^>]*>\([\s\S]*?,\s*\[/.test(src) }).toEqual({
file,
passesParams: true
});
}
});
});
7 changes: 7 additions & 0 deletions packages/express-context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ export type {
ApiError,
ApiStructure,
AuthSettings,
AuthSurface,
BillingConfig,
BuiltinModuleMap,
ComputeConfig,
ComputeModuleConfig,
ConstructiveAPIToken,
ConstructiveContext,
DatabaseSettings,
IdentityProviderConfig,
IdentityProvidersModule,
InferenceLogConfig,
LlmConfig,
PubkeyChallengeSettings,
Expand Down Expand Up @@ -85,16 +88,20 @@ export type {
export {
agentChatLoader,
authSettingsLoader,
authSurfaceLoader,
billingLoader,
computeLoader,
corsLoader,
createDefaultRegistry,
createLoaderRegistry,
createModuleLoader,
databaseSettingsLoader,
identityProvidersLoader,
inferenceLogLoader,
llmLoader,
pubkeyLoader,
requireDatabaseId,
requireIdentityProvider,
rlsLoader,
webauthnLoader,
} from './loaders';
Expand Down
10 changes: 9 additions & 1 deletion packages/express-context/src/loaders/agent-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
*
* Resolves per-database agent chat config from metaschema_modules_public.agent_chat_module.
* Returns the schema and table names for threads, messages, and tasks.
*
* Keyed by `database_id` like every other module lookup: one serving database
* holds several tenants' schemas, so an unkeyed discovery resolves a
* neighbouring tenant's tables instead of failing.
*/

import type { AgentChatConfig } from '../types';
import { createModuleLoader } from './create-loader';
import type { LoaderContext, ModuleLoader } from './types';
import { requireDatabaseId } from './types';

// ─── SQL ────────────────────────────────────────────────────────────────────

Expand All @@ -19,6 +24,7 @@ const AGENT_CHAT_MODULE_SQL = `
acm.task_table_name
FROM metaschema_modules_public.agent_chat_module acm
JOIN metaschema_public.schema s ON s.id = acm.schema_id
WHERE acm.database_id = $1
LIMIT 1
`;

Expand All @@ -37,10 +43,12 @@ export const agentChatLoader: ModuleLoader<AgentChatConfig> = createModuleLoader
name: 'agentChat',
ttlMs: 60_000,
async resolve(ctx: LoaderContext) {
const { tenantPool } = ctx;
const { tenantPool, databaseId } = ctx;
requireDatabaseId(databaseId, 'agentChat');

const result = await tenantPool.query<AgentChatModuleRow>(
AGENT_CHAT_MODULE_SQL,
[databaseId],
);
const row = result.rows[0];
if (!row) return undefined;
Expand Down
14 changes: 12 additions & 2 deletions packages/express-context/src/loaders/auth-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@
*
* This is the pattern for any module whose config lives in the tenant
* database rather than the routing database.
*
* Discovery is keyed by `ctx.databaseId`. One serving database holds several
* tenants' schemas in the normal schema-per-tenant topology, so an unfiltered
* discovery returns whichever row the planner reaches first and step 2 then
* reads a *neighbouring tenant's* cookie/captcha policy — and the loader cache
* makes that resolution sticky for its TTL.
*/

import type { AuthSettings } from '../types';
import { createModuleLoader } from './create-loader';
import type { LoaderContext, ModuleLoader } from './types';
import { requireDatabaseId } from './types';

// ─── SQL ────────────────────────────────────────────────────────────────────

const AUTH_SETTINGS_DISCOVERY_SQL = `
SELECT s.schema_name, sm.auth_settings_table_name AS table_name
FROM metaschema_modules_public.sessions_module sm
JOIN metaschema_public.schema s ON s.id = sm.schema_id
WHERE sm.database_id = $1
LIMIT 1
`;

Expand Down Expand Up @@ -58,11 +66,13 @@ export const authSettingsLoader: ModuleLoader<AuthSettings> = createModuleLoader
name: 'authSettings',
ttlMs: 5 * 60_000,
async resolve(ctx: LoaderContext) {
const { tenantPool } = ctx;
const { tenantPool, databaseId } = ctx;
requireDatabaseId(databaseId, 'authSettings');

// Step 1: Discover schema + table from sessions_module
const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(
AUTH_SETTINGS_DISCOVERY_SQL
AUTH_SETTINGS_DISCOVERY_SQL,
[databaseId]
);
const resolved = discovery.rows[0];
if (!resolved) return undefined;
Expand Down
Loading
Loading