@@ -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`: e.g.
907+ /// `writer = "postgres"` while `postgres` is not in `stores` (reads still
908+ /// served by vault) is a valid shadow-write -- it confirms writes land
909+ /// before reads start trusting Postgres -- and only 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.
@@ -4156,6 +4214,11 @@ firmware_url = "https://firmware.example.com/fw-b.bin"
41564214 secrets. import_approach,
41574215 crate :: secrets:: ImportApproach :: MissingOnly
41584216 ) ;
4217+
4218+ // stores/writer were omitted above, so they default to vault-only
4219+ // (env/file are prepended separately) writing to vault.
4220+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Vault ] ) ;
4221+ assert_eq ! ( secrets. writer, WriterKind :: Vault ) ;
41594222 }
41604223
41614224 // Verifies that a typo'd import source fails config parsing instead of
@@ -4186,6 +4249,91 @@ firmware_url = "https://firmware.example.com/fw-b.bin"
41864249 assert ! ( toml:: from_str:: <Wrapper >( toml_str) . is_err( ) ) ;
41874250 }
41884251
4252+ // Verifies the stores list and writer parse from their enum values --
4253+ // a mid-rollout config (Postgres in front of vault, writes to Postgres)
4254+ // and a fully-migrated one (vault dropped, writes to Postgres).
4255+ #[ test]
4256+ fn secrets_config_parses_stores_and_writer ( ) {
4257+ #[ derive( Deserialize ) ]
4258+ struct Wrapper {
4259+ secrets : SecretsConfig ,
4260+ }
4261+
4262+ let mid = r#"
4263+ [secrets]
4264+ stores = ["postgres", "vault"]
4265+ writer = "postgres"
4266+
4267+ [secrets.kms]
4268+ active = "local"
4269+ [secrets.kms.providers.local]
4270+ type = "integrated"
4271+ keys.default-key = { env = "K" }
4272+
4273+ [secrets.routing]
4274+ "/" = "default-key"
4275+ "# ;
4276+ let secrets = toml:: from_str :: < Wrapper > ( mid) . expect ( "parse mid" ) . secrets ;
4277+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Postgres , StoreKind :: Vault ] ) ;
4278+ assert_eq ! ( secrets. writer, WriterKind :: Postgres ) ;
4279+
4280+ // Fully migrated: postgres-only reads, writes to postgres too. (The
4281+ // writer-defaults-to-vault case is covered by the deserialize test
4282+ // above, with vault still in stores -- pairing a postgres-only chain
4283+ // with a vault writer is the read-after-write gap run.rs warns about.)
4284+ let migrated = r#"
4285+ [secrets]
4286+ stores = ["postgres"]
4287+ writer = "postgres"
4288+
4289+ [secrets.kms]
4290+ active = "local"
4291+ [secrets.kms.providers.local]
4292+ type = "integrated"
4293+ keys.default-key = { env = "K" }
4294+
4295+ [secrets.routing]
4296+ "/" = "default-key"
4297+ "# ;
4298+ let secrets = toml:: from_str :: < Wrapper > ( migrated)
4299+ . expect ( "parse migrated" )
4300+ . secrets ;
4301+ assert_eq ! ( secrets. stores, vec![ StoreKind :: Postgres ] ) ;
4302+ assert_eq ! ( secrets. writer, WriterKind :: Postgres ) ;
4303+ }
4304+
4305+ // Verifies a typo'd reader or writer value fails parsing rather than
4306+ // silently dropping a backend from the chain.
4307+ #[ test]
4308+ fn secrets_config_rejects_unknown_reader_or_writer_kind ( ) {
4309+ #[ derive( Deserialize ) ]
4310+ struct Wrapper {
4311+ #[ expect( dead_code) ]
4312+ secrets : SecretsConfig ,
4313+ }
4314+
4315+ let base_kms = r#"
4316+ [secrets.kms]
4317+ active = "local"
4318+ [secrets.kms.providers.local]
4319+ type = "integrated"
4320+ keys.default-key = { env = "K" }
4321+ [secrets.routing]
4322+ "/" = "default-key"
4323+ "# ;
4324+
4325+ let bad_reader = format ! ( "[secrets]\n stores = [\" postgrez\" ]\n {base_kms}" ) ;
4326+ assert ! ( toml:: from_str:: <Wrapper >( & bad_reader) . is_err( ) ) ;
4327+
4328+ // env/file are local overrides, not store readers -- they belong in
4329+ // [credentials.*], not [secrets].stores, so they're rejected here.
4330+ let env_as_reader = format ! ( "[secrets]\n stores = [\" env\" ]\n {base_kms}" ) ;
4331+ assert ! ( toml:: from_str:: <Wrapper >( & env_as_reader) . is_err( ) ) ;
4332+
4333+ let bad_writer = format ! ( "[secrets]\n writer = \" valt\" \n {base_kms}" ) ;
4334+ assert ! ( toml:: from_str:: <Wrapper >( & bad_writer) . is_err( ) ) ;
4335+ }
4336+
41894337 // Verifies that a misspelled optional key in [secrets] -- here
41904338 // `import_fom` for `import_from` -- fails to parse instead of leaving
41914339 // the import silently disabled. Without deny_unknown_fields, the typo'd
0 commit comments