08 — Configuration
Shape
A single YAML file. Everything operational lives here. No per-environment forks of the binary; one binary, many configs.
# config.yaml — illustrative, not final schema
gateway:
bind: 0.0.0.0:8443
external_url: https://db.internal.acme.com
env: production # rejects inline secrets when 'production'
state_db:
url: ${ENV:STATE_DB_URL} # gateway's own Postgres
pool_size: 10
auth:
oidc:
issuer: https://acme.okta.com
client_id: ${ENV:OIDC_CLIENT_ID}
client_secret: ${ENV:OIDC_CLIENT_SECRET}
groups_claim: groups # or 'scim' / 'directory_api'
session_ttl_hours: 8
servers:
- name: prod
kind: postgres
host: prod-rw.db.internal
port: 5432
tls: required
databases:
- name: app
role: mcp_gateway_prod_app_ro
password: vault:secret/prod/db/app_ro_password
description: "Main customer-facing app DB"
sql_capture: redacted
pool:
max_connections: 5
- name: billing
role: mcp_gateway_prod_billing_ro
password: vault:secret/prod/db/billing_ro_password
sql_capture: metadata_only
pool: { max_connections: 3 }
- name: staging
kind: postgres
host: staging.db.internal
port: 5432
tls: required
databases:
- name: app
role: mcp_gateway_staging_app_ro
password: ${FILE:/run/secrets/staging-app-ro-password}
permissions:
- group: backend-engineers
grants:
- { server: staging, database: "*", action: query_read }
- { server: prod, database: "*", action: schema_read }
- group: oncall
grants:
- server: prod
database: "*"
action: query_read
constraints:
require_reason: true
statement_timeout_ms: 5000
row_limit: 1000
# Service-token group (spec 14). Each entry is the single permissions
# group a `service_accounts:` token acts as. Empty `grants:` is valid —
# it recognizes a group that authorizes nothing (authentication never
# implies authorization).
- group: svc-ci-bot
grants: [] # fill in the minimal grants this service needs
# Optional. Absent ⇒ /admin/v1/* returns 404, YAML-only permissions path.
admin:
enabled: true
group: db-admins # SSO group claim authorising admin calls
# Optional. Absent ⇒ pg (state DB) backs users/databases/grants.
permissions_store:
driver: pg # or 'mysql' — see boot-gate below
# Optional. Absent ⇒ no static bearer tokens accepted (OIDC sessions only).
# Headless-client credentials — name + one permissions group + secret ref.
# Full design: 14-service-tokens.md.
service_accounts:
- name: ci-bot
group: svc-ci-bot # must exist in permissions:; never the admin group
token: ${ENV:SERVICE_TOKEN_CI_BOT}
logging:
hot_retention_days: 90
archive:
kind: s3
bucket: acme-db-mcp-audit
prefix: gateway/
region: us-east-1
stream:
- kind: otlp
endpoint: https://otel.internal:4317
Resolution order
- File at
--configflag, else$DB_MCP_GATEWAY_CONFIG, else/etc/gateway/config.yml. ${ENV:NAME}placeholders expanded from process env;${FILE:/path}placeholders read from disk (trailing newline stripped). Unresolved or empty refs abort boot.vault:,aws-sm:,gcp-sm:references resolved from the named backend (when backend integrations land — until then, recognised refs also abort boot rather than silently failing at first DB connect).- Final config validated against the schema. Errors at this stage refuse to start the process — no half-loaded state.
Validation rules (non-exhaustive)
env: production⇒ inline literal passwords rejected (must be reference).- Every
permissions[*].groupmust be a syntactically valid group name; gateway warns (not errors) if no IdP user is in the group — groups can be defined ahead of population. - Every
permissions[*].grants[*].server/databasemust exist inservers, or be the literal"*". - Every server's
tlsmust berequiredwhenenv: productionunless explicitlytls: insecure(which logs a warning every minute). - Role names must match
^[a-zA-Z_][a-zA-Z0-9_]*$. Catch typos before they become connection failures. - A database's
rolemust not be the stock superuser account its backend ships with (postgresfor Postgres,rootfor MySQL,safor MSSQL,root/adminfor Mongo). Matched case-insensitively on the name only — a superuser renamed toappstill passes; the real least-privilege guarantee lives target-side. Rejected at boot so the "never run as DB superuser" rule (see 05-credentials.md) can't be defeated by a config typo. - Every server's
kindmust have a query adapter wired (todaypostgresandmongo). Amysql/mssqltarget is rejected at boot rather than parsing clean and failing every query at runtime — these kinds are reserved for the roadmap adapters and only become valid when their adapters land. MCP_PATH(env-driven for now, folded into YAML with #16) must not collide with a path the gateway already mounts. Reserved exact paths:/healthz,/readyz,/metrics,/auth/login,/auth/callback,/auth/logout,/authorize,/token,/revoke,/register. Reserved route families (the path and anything under<prefix>/…):/admin(owned by the admin API — reserved unconditionally so togglingadmin.enabledcan't turn a working config into a boot panic) and/.well-known(RFC 8414/9728 discovery metadata). Segment-based match:/adminyor/tokensare fine. Overlap surfaces as a typed boot error naming the offending path, not an axum router panic.admin.enableddefaults tofalse— absent or false leaves/admin/v1/*unmounted (404). Whenenabled: true,admin.groupis required and must be non-empty/non-whitespace, else boot aborts (every authenticated caller would otherwise be an admin). Full surface in 12-dynamic-permissions.md.permissions_store.driver: mysqlcombined withadmin.enabled: trueis rejected at boot — admin handlers are pg-only today. Usedriver: pg(the default when the block is absent) for the admin path, ormysqlwith YAML grants only.
YAML parser
Config YAML is parsed only through config::yaml::non_leaking_options — never serde_saphyr::from_str. A config file is mostly credentials, so a parse failure is a disclosure risk.
Crate: serde-saphyr, the serde front-end for saphyr (yaml-rust2 successor).
| Candidate | Status | Verdict |
|---|---|---|
serde_yaml | archived Mar 2024 | rejected — no fixes, and it parses every boot |
serde_yml | archived | rejected — the popular fork is dead too |
serde_yaml_ng | ~1 yr idle | rejected — less audit surface than what it replaces |
serde_norway | ~1 yr idle | rejected — same |
serde-saphyr | 1.0, active | chosen |
The ecosystem never migrated off serde_yaml; it still outpulls every fork combined. Swapping to an idle fork trades a scrutinised frozen crate for an unscrutinised quiet one.
Parse errors never quote the file
A parse error carries a position and the schema's expectations, never the file's content. Both come from the gateway's own types:
Error: /etc/gateway/config.yml:3:11: invalid configuration (expected one of postgres, mysql, mssql, mongo)
Three independent controls enforce this. Each has been observed to fail alone.
| Control | Without it |
|---|---|
Snippet rendering disabled (non_leaking_options) | Parser attaches a window of surrounding source — discloses neighbouring credentials even when the error is elsewhere |
Error stores no parser text — only position + the text after expected one of | The offending scalar rides along in the message |
Parse has no #[source] | main returns anyhow::Result; anyhow prints every source() link under Caused by:, so a "suppressed" message still reaches the terminal |
The third was missed originally: suppressing at Display is not sufficient, the chain must be severed. Re-attaching a #[source] reopens it, so a test asserts the chain has no second link.
Extraction is an allowlist, not a redaction. serde puts the input token before expected one of and the schema's alternatives after it, so copying only the tail is safe by construction — a redaction rule would have to track the parser's phrasing, whereas this can only fail closed by finding no marker.
Operators lose nothing: :line:column points at the offending value in a file they already have, and the list names what the field accepts.
Hot reload
SIGHUP re-reads the file. On success, swaps live config atomically. On failure, keeps the old config and logs the error — never half-applies. Pools for removed databases drain; new pools for added databases come up lazily.
What is not in config
- Anything user-facing. No UI strings, no branding, no email templates (this isn't that kind of product).
- Per-user permissions. Users get permissions through groups, period.
- DB schemas. The gateway reads the DB's own schema; it doesn't maintain a separate model.