Skip to content

feat(config,db): load YAML configuration and connect to Postgres - #6

Merged
stephane-segning merged 1 commit into
masterfrom
claude/config-and-persistence
Aug 10, 2026
Merged

feat(config,db): load YAML configuration and connect to Postgres#6
stephane-segning merged 1 commit into
masterfrom
claude/config-and-persistence

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Summary

The two things every remaining piece of work was blocked on: configuration loading and a database connection. Neither the OpenID Provider nor any /v1 route could be built while --config was parsed and ignored and nothing ever opened a pool.

Nothing here makes authentication work, and docs/status.md is written to make that unmissable. The router is still /healthz plus the Stripe-shaped 404: no /v1 route, no /dash/v1 route, no client store, no ClientAssertionStore, no kill-switch check, no signing-key generation or rotation. authkestra-op remains a dev-dependency of the integration test crate alone. The schema and config loader are real and tested; the auth is not built.

Source of truth: ADR-0003 (administration is YAML in git; a profile selects a config file, never a code path), ADR-0004 (static musl into scratch — load-bearing for the TLS fix below), and the new ADR-0010, recording the maintainer's decision to move merchant auth off API keys.

Scope

ConfigurationConfig::load layers application.yml over application-{profile}.yml, resolves ${ENV}, then validates via the existing tested guard rules plus garde. vpay-config went 5 → 36 tests. It is a library only; neither binary calls it yet, so --config is still ignored at runtime.

Persistence — new vpay-db: connect, run_migrations, check_connection. Both binaries require a database at boot, run migrations before binding, and /healthz performs a real SELECT 1.

Schema cutover (00090012) — merchant_api_keys dropped; oauth_signing_keys reshaped to public_jwk JSONB with private_key_pem removed entirely; oauth_client_assertion_jtis and disabled_clients added. No secret material is stored anywhere in the schema now.

ADR-0010 — merchants authenticate with client_credentials + private_key_jwt from YAML, public JWK only. Device flow dropped, no refresh tokens, dashboard read-only with one scope. README.md, docs/api/README.md and examples/** corrected: Stripe-shaped now means object model, idempotency and webhook signatures, explicitly not auth — no Stripe SDK can authenticate against vpay.

Verification

Check Before After
cargo nextest run --workspace 80 passed / 3 skipped 105 passed / 3 skipped
just ci exit 0 exit 0
cargo deny check clean clean

The 3 skipped are unchanged adapter-conformance ignores. Every new constraint has a test that inserts a violating row and asserts db_err.constraint() names that specific constraint.

Risk Assessment

Two defects were found and fixed during review — both are the interesting part of this PR.

1. TLS roots would have failed only in production. A licence failure on webpki-roots' CDLA-Permissive-2.0 was first worked around by switching sqlx to tls-rustls-ring-native-roots. That reads the OS trust store — and the runtime image is FROM scratch per ADR-0004, so there is none. TLS to Postgres would have failed in the shipped image while passing locally and in CI, where connections are plain. Reverted to vendored roots; the licence is allow-listed in deny.toml with justification (it covers Mozilla's CA data, not code, and is permissive). rustls-native-certs now reaches the graph only via bollard → testcontainers → vpay-testkit [dev-dependencies].

2. Credentials would have leaked into logs. ProviderHost.credentials and CommonArgs.database_url were reachable through derived Debug — one tracing::debug!(?args) from live rail secrets and a Postgres password. Hand-written redacting impls now. Config/ServerArgs/WorkerArgs keep derive, which is safe because derive delegates to each field's own impl; a test proves the composition rather than asserting it.

Known gaps, recorded rather than hidden:

  • Config loading is not wired into either binary. --config is still inert.
  • Two boot-guard rules from docs/flows/configuration.md are unimplemented because merchant clients are deliberately unmodelled — ADR-0010 was in flight and a guessed shape would have been fabrication.
  • oauth_client_assertion_jtis has no cleanup job (the worker loop is still ⛔), so it grows unbounded.
  • Database connectivity is 🟡, not ✅: the hard failure on a missing --database-url and /healthz returning 503 against a dead database both lack tests. Only check_connection()'s failure path is unit-tested.
  • settings and credentials are both BTreeMap<String, String> and only credentials is redacted — that boundary is convention, not type. A Secret<String> newtype would make it structural.

AI Usage Declaration

AI (Claude Opus 5 via Claude Code) performed this work, delegating five features to parallel Sonnet sub-agents on disjoint file sets. The orchestrating session re-ran every gate and verified load-bearing claims independently — which caught the TLS regression, the credential-leak hazard, and three stale doc claims (including a self-contradictory module comment introduced by the TLS revert). One agent correctly downgraded a row from ✅ to 🟡 against my own brief, on the grounds that two claims I stated had no test; I accepted its judgement.

  • A human is accountable for this change and has reviewed it.
  • Every claim here was verified by running the command, not inferred.
  • Limitations stated explicitly — see Risk Assessment.
  • No functionality was fabricated; the 8 NotImplemented tokens are unchanged.

Reviewer Focus

  1. deny.toml's new CDLA-Permissive-2.0 allowance — a licence-policy decision, and the alternative silently breaks the scratch image.
  2. 0010_reshape-oauth-signing-keys.sql — is public-JWK-in-DB plus PEM-from-Secret the shape you want before any key is ever generated?
  3. The unwired config loader. It is deliberate (--config inert), but it is the one place this PR adds capability nothing calls.

The two things every remaining piece of work was blocked on. Neither the
auth engine nor any /v1 route could be built while --config was parsed and
ignored and nothing ever opened a database connection.

Nothing here makes authentication work. The router is still /healthz plus
the Stripe-shaped 404: no /v1 route, no /dash/v1 route, no client store,
no ClientAssertionStore, no kill-switch check, no signing-key generation
or rotation. authkestra-op remains a dev-dependency of the integration
test crate alone. The schema and the config loader are real and tested;
the auth is not built, and docs/status.md says so plainly.

Configuration (vpay-config, library only — not yet wired into either
binary, so --config is still ignored at runtime):

* Config::load layers application.yml over application-{profile}.yml,
  resolves ${ENV} placeholders, then validates.
* ${ENV} interpolation is hand-rolled: figment's Env provider reads
  variables into the tree but does not interpolate inside YAML scalars.
  Resolution runs before typed deserialization, so an unresolved
  placeholder is fatal for any field, as docs/flows/configuration.md
  requires — not just for credentials.
* The env lookup is injected rather than read from the process, because
  std::env::set_var is unsafe in edition 2024 and the workspace forbids
  unsafe with no test carve-out. That makes the failure path testable.
* Merchant and dashboard OAuth clients are deliberately NOT modelled —
  ADR-0010 was being written concurrently and a guessed client shape
  would be fabrication. Two boot-guard rules stay unimplemented as a
  result and are recorded as gaps.
* 5 -> 36 tests.

Persistence (new vpay-db crate):

* connect, run_migrations (sqlx::migrate!), check_connection, DbError.
* Both binaries now require a database at boot, run migrations before
  binding, and /healthz performs a real SELECT 1 returning 503 when the
  database is unreachable.
* Liveness and readiness deliberately not split: no k8s manifests exist
  to justify the distinction yet.

Schema cutover to the settled auth decisions (0009-0012):

* merchant_api_keys dropped — merchants authenticate with
  client_credentials + private_key_jwt (ADR-0010), so there is no key to
  store.
* oauth_signing_keys reshaped: private_key_pem removed entirely,
  public_jwk JSONB added, id renamed kid. No secret material is stored
  anywhere in the schema now. The private PEM comes from a Kubernetes
  Secret at boot; TokenManager::new_asymmetric parses it once at
  construction and retains only derived keys, so the database never
  needs it again.
* oauth_client_assertion_jtis for private_key_jwt replay protection —
  the primary key is the atomic guard. No cleanup job exists yet, so it
  grows unbounded; that is recorded, not handled.
* disabled_clients, the kill switch: YAML stays authoritative for
  identity, but an operator can revoke instantly without a deploy. It
  stores no credential. The cost is that config is no longer the sole
  authority, so a runbook must read both.
* oauth_refresh_tokens and oauth_device_codes are kept deliberately.
  OpStore's supertrait bound forces concrete types in those slots; "no
  refresh tokens, no device flow" is enforced by never mounting those
  handlers, not by the schema.

Two defects fixed after review:

* Secret redaction. ProviderHost and CommonArgs now have hand-written
  Debug impls; a derived one would have printed rail credentials and the
  database password on any tracing::debug!(?args). Config, ServerArgs and
  WorkerArgs keep derive — it delegates to each field's own impl — and a
  test proves the composition holds.
* TLS roots. sqlx is pinned to tls-rustls-ring (vendored webpki-roots),
  not native-roots. A licence failure on webpki-roots' CDLA-Permissive-2.0
  was first worked around by switching to native roots, which reads the OS
  trust store — and the runtime image is FROM scratch (ADR-0004), so there
  is none. That would have failed only in the shipped image, while passing
  locally and in CI where connections are plain. The licence is allowed in
  deny.toml instead, with justification: it covers Mozilla's CA data, not
  code, and is permissive. rustls-native-certs now reaches the graph only
  through bollard -> testcontainers -> vpay-testkit [dev-dependencies].

Verified: just ci exits 0. 105 passed / 3 skipped, up from 80/3; the 3
skipped are the unchanged adapter-conformance ignores. cargo deny clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 615d0f8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@stephane-segning
stephane-segning merged commit 932d8a4 into master Aug 10, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant