@@ -730,9 +730,9 @@ pub struct CarbideConfig {
730730 #[ serde( default ) ]
731731 pub tracing : TracingConfig ,
732732
733- /// Secrets backend configuration. When present, credentials live
734- /// encrypted in Postgres and vault leaves the credential chain
735- /// entirely; when absent, vault remains the credential store .
733+ /// Secrets backend configuration. When present, the credential reader
734+ /// chain and write target are operator-configured (defaulting to the same
735+ /// env -> file -> vault behavior as when it is absent); see `SecretsConfig` .
736736 pub secrets : Option < SecretsConfig > ,
737737}
738738
@@ -846,24 +846,25 @@ pub enum BgpLeafSessionPassword {
846846 SiteWide ,
847847}
848848
849- /// Configures the Postgres secrets backend. When this section is present,
850- /// credentials live encrypted in Postgres and vault is not in the
851- /// credential chain at all -- the one-time import either completes before
852- /// the process serves traffic, or the process does not start. Vault keeps
853- /// serving PKI certificates either way.
849+ /// Configures the Postgres secrets backend and how credentials flow. When
850+ /// this section is present the reader chain and the write target come from
851+ /// `stores` / `writer` below; their defaults keep today's behavior
852+ /// (env -> file -> vault, writes to vault), so adding `[secrets]` does not
853+ /// change credential routing on its own. An operator rolls Postgres in
854+ /// gradually by editing `stores` and flipping `writer`. Vault keeps serving
855+ /// PKI certificates regardless of the chain.
854856///
855- /// Enabling this on an existing site has two prerequisites that live
856- /// outside this process :
857+ /// Two prerequisites live outside this process and matter once writes move
858+ /// to Postgres (`writer = "postgres"`) or vault leaves `stores` :
857859///
858860/// - Services that read credentials from vault through their own chains
859- /// (`bmc-proxy`, `dsx-exchange-consumer`) keep reading vault and will
860- /// not see anything carbide-api writes to Postgres afterwards. They must
861- /// be migrated or fed another way before credentials here change.
862- /// - During a rolling upgrade, replicas still running the vault config
863- /// keep writing rotated credentials to vault, where they are stranded
864- /// once the import has completed. Keep autonomous credential writers
865- /// (site-explorer credential rotation) disabled until the whole fleet
866- /// runs this config.
861+ /// (`bmc-proxy`, `dsx-exchange-consumer`) will not see anything carbide-api
862+ /// writes to Postgres. They must be migrated or fed another way before the
863+ /// credentials they read change.
864+ /// - During a rolling upgrade, replicas still on an older config keep writing
865+ /// rotated credentials to their own writer. Keep autonomous credential
866+ /// writers (site-explorer credential rotation) disabled until the whole
867+ /// fleet runs a consistent config.
867868#[ derive( Clone , Debug , Deserialize , Serialize ) ]
868869#[ serde( deny_unknown_fields) ]
869870pub struct SecretsConfig {
@@ -884,9 +885,36 @@ pub struct SecretsConfig {
884885 /// ```
885886 pub routing : std:: collections:: HashMap < String , String > ,
886887
888+ /// The credential *store* read order, highest priority first (first match
889+ /// wins). The local-override readers (env, file) are always tried ahead of
890+ /// these, when their `[credentials.*]` section is enabled; this list only
891+ /// orders the stores behind them. Defaults to `["vault"]` -- with the local
892+ /// overrides, that is the pre-Postgres env -> file -> vault chain. Roll
893+ /// Postgres in gradually by editing this list:
894+ ///
895+ /// 1. `["vault"]` -- Postgres configured but not yet read.
896+ /// 2. `["postgres", "vault"]` -- Postgres in front, vault as the safety net
897+ /// for anything Postgres misses.
898+ /// 3. `["postgres"]` -- fully migrated, vault dropped.
899+ ///
900+ /// Postgres must come before vault; an empty list, or a store listed twice,
901+ /// fails the boot.
902+ #[ serde( default = "default_secret_stores" ) ]
903+ pub stores : Vec < StoreKind > ,
904+
905+ /// Where new credential writes go. Defaults to `vault`; set to `postgres`
906+ /// to send new writes to the journal. Independent of `stores` -- a
907+ /// `postgres` writer with vault still in `stores` is a valid
908+ /// shadow-write setup (write to Postgres, keep reading vault) and only
909+ /// logs a warning.
910+ #[ serde( default ) ]
911+ pub writer : WriterKind ,
912+
887913 /// A source backend to import secrets from at startup. Unset means a
888914 /// fresh site with nothing to import; unsupported values fail config
889- /// parsing rather than silently skipping the import.
915+ /// parsing rather than silently skipping the import. Independent of
916+ /// `stores`/`writer` -- importing from vault is orthogonal to where
917+ /// reads and writes flow.
890918 pub import_from : Option < ImportSource > ,
891919
892920 /// How to treat secrets that already exist in Postgres during import.
@@ -902,6 +930,36 @@ pub enum ImportSource {
902930 Vault ,
903931}
904932
933+ /// A credential *store* backend, named in `[secrets].stores` to order the
934+ /// stores behind the always-first local overrides (env, file). First match in
935+ /// the chain wins (see `ChainedCredentialReader`).
936+ #[ derive( Clone , Copy , Debug , PartialEq , Eq , Hash , Deserialize , Serialize ) ]
937+ #[ serde( rename_all = "lowercase" ) ]
938+ pub enum StoreKind {
939+ /// The Postgres secrets journal.
940+ Postgres ,
941+ /// Vault/OpenBao KV.
942+ Vault ,
943+ }
944+
945+ /// The single backend new credential writes go to.
946+ #[ derive( Clone , Copy , Debug , Default , PartialEq , Eq , Deserialize , Serialize ) ]
947+ #[ serde( rename_all = "lowercase" ) ]
948+ pub enum WriterKind {
949+ /// Writes go to Vault/OpenBao KV (today's behavior).
950+ #[ default]
951+ Vault ,
952+ /// Writes go to the Postgres secrets journal.
953+ Postgres ,
954+ }
955+
956+ /// The default backend-store order (just vault). With the always-first env/file
957+ /// local overrides, this is the pre-Postgres chain env -> file -> vault, so
958+ /// adding `[secrets]` changes nothing until an operator edits it.
959+ fn default_secret_stores ( ) -> Vec < StoreKind > {
960+ vec ! [ StoreKind :: Vault ]
961+ }
962+
905963/// Configures the KMS backends that wrap DEKs. Several named providers can
906964/// be defined: the active one wraps DEKs for new writes, and every provider
907965/// answers unwraps for the kek_ids it has.
@@ -4155,6 +4213,11 @@ firmware_url = "https://firmware.example.com/fw-b.bin"
41554213 secrets. import_approach,
41564214 crate :: secrets:: ImportApproach :: MissingOnly
41574215 ) ;
4216+
4217+ // stores/writer were omitted above, so they default to vault-only
4218+ // (env/file are prepended separately) writing to vault.
4219+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Vault ] ) ;
4220+ assert_eq ! ( secrets. writer, WriterKind :: Vault ) ;
41584221 }
41594222
41604223 // Verifies that a typo'd import source fails config parsing instead of
@@ -4185,6 +4248,91 @@ firmware_url = "https://firmware.example.com/fw-b.bin"
41854248 assert ! ( toml:: from_str:: <Wrapper >( toml_str) . is_err( ) ) ;
41864249 }
41874250
4251+ // Verifies the reader chain and writer parse from their enum values --
4252+ // a mid-rollout config (Postgres in front of vault, writes to Postgres)
4253+ // and a fully-migrated one (vault dropped, writer defaulted).
4254+ #[ test]
4255+ fn secrets_config_parses_stores_and_writer ( ) {
4256+ #[ derive( Deserialize ) ]
4257+ struct Wrapper {
4258+ secrets : SecretsConfig ,
4259+ }
4260+
4261+ let mid = r#"
4262+ [secrets]
4263+ stores = ["postgres", "vault"]
4264+ writer = "postgres"
4265+
4266+ [secrets.kms]
4267+ active = "local"
4268+ [secrets.kms.providers.local]
4269+ type = "integrated"
4270+ keys.default-key = { env = "K" }
4271+
4272+ [secrets.routing]
4273+ "/" = "default-key"
4274+ "# ;
4275+ let secrets = toml:: from_str :: < Wrapper > ( mid) . expect ( "parse mid" ) . secrets ;
4276+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Postgres , StoreKind :: Vault ] ) ;
4277+ assert_eq ! ( secrets. writer, WriterKind :: Postgres ) ;
4278+
4279+ // Fully migrated: postgres-only reads, writes to postgres too. (The
4280+ // writer-defaults-to-vault case is covered by the deserialize test
4281+ // above, with vault still in stores -- pairing a postgres-only chain
4282+ // with a vault writer is the read-after-write gap run.rs warns about.)
4283+ let migrated = r#"
4284+ [secrets]
4285+ stores = ["postgres"]
4286+ writer = "postgres"
4287+
4288+ [secrets.kms]
4289+ active = "local"
4290+ [secrets.kms.providers.local]
4291+ type = "integrated"
4292+ keys.default-key = { env = "K" }
4293+
4294+ [secrets.routing]
4295+ "/" = "default-key"
4296+ "# ;
4297+ let secrets = toml:: from_str :: < Wrapper > ( migrated)
4298+ . expect ( "parse migrated" )
4299+ . secrets ;
4300+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Postgres ] ) ;
4301+ assert_eq ! ( secrets. writer, WriterKind :: Postgres ) ;
4302+ }
4303+
4304+ // Verifies a typo'd reader or writer value fails parsing rather than
4305+ // silently dropping a backend from the chain.
4306+ #[ test]
4307+ fn secrets_config_rejects_unknown_reader_or_writer_kind ( ) {
4308+ #[ derive( Deserialize ) ]
4309+ struct Wrapper {
4310+ #[ expect( dead_code) ]
4311+ secrets : SecretsConfig ,
4312+ }
4313+
4314+ let base_kms = r#"
4315+ [secrets.kms]
4316+ active = "local"
4317+ [secrets.kms.providers.local]
4318+ type = "integrated"
4319+ keys.default-key = { env = "K" }
4320+ [secrets.routing]
4321+ "/" = "default-key"
4322+ "# ;
4323+
4324+ let bad_reader = format ! ( "[secrets]\n stores = [\" postgrez\" ]\n {base_kms}" ) ;
4325+ assert ! ( toml:: from_str:: <Wrapper >( & bad_reader) . is_err( ) ) ;
4326+
4327+ // env/file are local overrides, not store readers -- they belong in
4328+ // [credentials.*], not [secrets].stores, so they're rejected here.
4329+ let env_as_reader = format ! ( "[secrets]\n stores = [\" env\" ]\n {base_kms}" ) ;
4330+ assert ! ( toml:: from_str:: <Wrapper >( & env_as_reader) . is_err( ) ) ;
4331+
4332+ let bad_writer = format ! ( "[secrets]\n writer = \" valt\" \n {base_kms}" ) ;
4333+ assert ! ( toml:: from_str:: <Wrapper >( & bad_writer) . is_err( ) ) ;
4334+ }
4335+
41884336 // Verifies that a misspelled optional key in [secrets] -- here
41894337 // `import_fom` for `import_from` -- fails to parse instead of leaving
41904338 // the import silently disabled. Without deny_unknown_fields, the typo'd
0 commit comments