From a69b90aa0e2b080cb7910085b0c2d2c281eb95af Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Wed, 6 May 2026 21:45:53 +0300 Subject: [PATCH] feat(discovery): support configured db schema --- docker-compose.yml | 1 + docs/deployment.md | 2 ++ services/discovery/src/db/client.ts | 34 +++++++++++++++++++-- services/discovery/src/migrate.ts | 3 +- services/discovery/test/storage.test.ts | 40 +++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 440587e..4fefd57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-fides}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:-fides} DISCOVERY_PORT: "3100" DISCOVERY_DB_AUTO_MIGRATE: ${DISCOVERY_DB_AUTO_MIGRATE:-true} + DISCOVERY_DB_SCHEMA: ${DISCOVERY_DB_SCHEMA:-discovery} NODE_ENV: production CORS_ORIGIN: ${CORS_ORIGIN:-} SERVICE_API_KEY: ${SERVICE_API_KEY:-} diff --git a/docs/deployment.md b/docs/deployment.md index 9b2a75e..536bb7d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -44,12 +44,14 @@ cp .env.example .env | Variable | Default | Required | Description | | --------------------------- | ------- | -------- | ----------- | | `DISCOVERY_DB_AUTO_MIGRATE` | `true` | no | Runs idempotent discovery migrations on startup and records applied ids plus checksums in `discovery_schema_migrations`. Set `false` when migrations are managed externally. | +| `DISCOVERY_DB_SCHEMA` | _(empty)_ | no | Optional Postgres schema name for discovery tables when multiple services share one database. Must be a simple identifier. | ### Trust Graph Store | Variable | Default | Required | Description | | ----------------------------- | ------- | -------- | ----------- | | `TRUST_GRAPH_DB_AUTO_MIGRATE` | `true` | no | Runs idempotent trust graph migrations on startup and records applied ids plus checksums in `trust_graph_schema_migrations`. Set `false` when migrations are managed externally. | +| `TRUST_GRAPH_DB_SCHEMA` | `trust_graph` in Docker Compose, otherwise _(empty)_ | no | Optional Postgres schema name for trust graph tables when multiple services share one database. Must be a simple identifier. | ### Agentd Authority Store diff --git a/services/discovery/src/db/client.ts b/services/discovery/src/db/client.ts index cedf062..0b23df8 100644 --- a/services/discovery/src/db/client.ts +++ b/services/discovery/src/db/client.ts @@ -7,14 +7,31 @@ const DEV_FALLBACK = 'postgresql://fides:fides@localhost:5432/fides' function getConnectionString(): string { const url = process.env.DATABASE_URL - if (url) return url + const schemaName = process.env.DISCOVERY_DB_SCHEMA + if (url) return withSearchPath(url, schemaName) if (process.env.NODE_ENV === 'production') { throw new Error('DATABASE_URL must be set in production') } console.warn('DATABASE_URL not set — using development fallback') - return DEV_FALLBACK + return withSearchPath(DEV_FALLBACK, schemaName) +} + +function withSearchPath(connectionString: string, schemaName?: string): string { + if (!schemaName) return connectionString + + assertValidSchemaName(schemaName) + + const url = new URL(connectionString) + url.searchParams.set('options', `-c search_path=${schemaName},public`) + return url.toString() +} + +function assertValidSchemaName(schemaName: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schemaName)) { + throw new Error('DISCOVERY_DB_SCHEMA must be a simple Postgres identifier') + } } const poolConfig = { @@ -28,6 +45,10 @@ const connectionString = getConnectionString() export const sql = postgres(connectionString, poolConfig) export const db = drizzle(sql, { schema }) +export function createRawClient(): postgres.Sql { + return postgres(getConnectionString(), poolConfig) +} + export const DISCOVERY_MIGRATIONS = [ { id: '001_initial', @@ -108,6 +129,7 @@ export async function runDiscoveryMigrations(client: postgres.Sql): Promise { + const schemaName = process.env.DISCOVERY_DB_SCHEMA + if (!schemaName) return + + assertValidSchemaName(schemaName) + await client.unsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`) +} + async function ensureDiscoveryMigrationLedger(client: postgres.Sql): Promise { await client` CREATE TABLE IF NOT EXISTS discovery_schema_migrations ( diff --git a/services/discovery/src/migrate.ts b/services/discovery/src/migrate.ts index 0905e8a..6edc21c 100644 --- a/services/discovery/src/migrate.ts +++ b/services/discovery/src/migrate.ts @@ -1,6 +1,7 @@ -import { runDiscoveryMigrations, sql } from './db/client.js' +import { createRawClient, runDiscoveryMigrations } from './db/client.js' async function main() { + const sql = createRawClient() try { await runDiscoveryMigrations(sql) console.log('discovery migrations applied') diff --git a/services/discovery/test/storage.test.ts b/services/discovery/test/storage.test.ts index 9819a78..d68def7 100644 --- a/services/discovery/test/storage.test.ts +++ b/services/discovery/test/storage.test.ts @@ -52,6 +52,46 @@ describe.skipIf(!postgresUrl)('discovery migrations', () => { } }, 30_000) + it('creates and uses the configured discovery schema', async () => { + const schema = `discovery_configured_${crypto.randomUUID().replaceAll('-', '')}` + const schemaIdentifier = quoteIdentifier(schema) + const adminSql = postgres(postgresUrl, { max: 1 }) + const scopedSql = postgres(postgresUrlWithSearchPath(postgresUrl, schema), { max: 1 }) + const previousSchema = process.env.DISCOVERY_DB_SCHEMA + + try { + process.env.DISCOVERY_DB_SCHEMA = schema + await runDiscoveryMigrations(scopedSql) + + const schemaRows = await adminSql` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name = ${schema} + ` + expect(schemaRows).toHaveLength(1) + + const tableRows = await adminSql` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = ${schema} + AND table_name IN ('identities', 'agents', 'discovery_schema_migrations') + ` + expect(tableRows).toHaveLength(3) + + const currentSchema = await scopedSql`SELECT current_schema() AS schema` + expect(currentSchema[0]?.schema).toBe(schema) + } finally { + if (previousSchema === undefined) { + delete process.env.DISCOVERY_DB_SCHEMA + } else { + process.env.DISCOVERY_DB_SCHEMA = previousSchema + } + await scopedSql.end() + await adminSql.unsafe(`DROP SCHEMA IF EXISTS ${schemaIdentifier} CASCADE`) + await adminSql.end() + } + }, 30_000) + it('fails closed when an applied discovery migration checksum drifts', async () => { const schema = `discovery_checksum_${crypto.randomUUID().replaceAll('-', '')}` const schemaIdentifier = quoteIdentifier(schema)