Skip to content

Commit 28e831b

Browse files
committed
feat(secrets): configurable credential chain and writer
Turning on the Postgres secrets backend used to be all-or-nothing: setting [secrets] dropped vault from the read chain and ran a mandatory import. Now the backend stores are operator config, so a site can roll Postgres in gradually -- Postgres in front of vault, vault as a safety net, writes flipped over when ready, vault dropped once fully migrated. Two new [secrets] fields drive it: - stores -- the backend-store read order (postgres, vault), first match wins. The env/file local overrides are always tried first (when their [credentials.*] section is enabled); this list just orders the stores behind them. Defaults to ["vault"], today's behavior. The rollout is editing this list: ["vault"] -> ["postgres", "vault"] -> ["postgres"]. - writer -- vault (default) or postgres. Flip it to send new writes to the journal. import_from stays fully independent -- importing from vault is orthogonal to where reads and writes flow, and it is now gated visibly at the call site. A small pure assemble_chain prepends the local overrides, orders the stores from config, and picks the writer, so the wiring is unit-testable without a database; run.rs just hands it the constructed backends. An empty or duplicate stores list fails the boot, as does putting vault ahead of postgres; a writer whose backend is not in stores is allowed but warns about the read-after-write gap (e.g. a deliberate postgres shadow-write). Tests added! This supports #2811 Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent 40c7ca4 commit 28e831b

4 files changed

Lines changed: 619 additions & 95 deletions

File tree

crates/api-core/src/cfg/file.rs

Lines changed: 167 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
869870
pub 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]\nstores = [\"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]\nstores = [\"env\"]\n{base_kms}");
4330+
assert!(toml::from_str::<Wrapper>(&env_as_reader).is_err());
4331+
4332+
let bad_writer = format!("[secrets]\nwriter = \"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

Comments
 (0)