Files
notfiles/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md
Joseph O'Brien 1359def673
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
feat(notsecrets): add multi-provider secret resolution system
Add a config-driven, strongly-typed secret/env resolution system to
notsecrets with 11 provider backends. The system supports explicit
key bindings with typed SecretRef enums and a priority chain fallback.

Tier 1 providers (implemented): Env, 1Password, dotenvx, SOPS, GSM
Tier 2 providers (stubbed): nuenv, direnv, mise, Bitwarden, Vault, dotenvy

New types: Provider, ProviderConfig, SecretRef, SecretsConfig,
SecretsError, SecretSource, EnumerableSecretSource, SecretResolver

Config lives in notsecrets.toml with typed serde deserialization.
ISP split: EnumerableSecretSource for backends that support bulk
enumeration, SecretSource for single-key lookup only.

Includes conformance test suite, 33 new tests (161 workspace total).
notstrap EnvInjector migration deferred to follow-up.
2026-06-06 09:49:05 -04:00

13 KiB

notsecrets Multi-Provider Secret Resolution

Date: 2026-06-06 Crate: notsecrets Status: Approved design, pending implementation plan

Goal

Extend notsecrets with a general-purpose secret/env resolution system alongside the existing age identity system. Multiple backends resolve arbitrary secrets (API keys, tokens, env vars) via a typed provider chain with explicit bindings.

Non-Goals

  • Replacing the existing IdentitySource / age identity system
  • Runtime hot-reloading of config
  • Secret rotation or lease management
  • Encrypting secrets (write path) -- read-only resolution

Architecture

Two independent port systems in one crate

notsecrets
  +-- Age identity system (existing, untouched)
  |     IdentitySource trait -> BitwardenSource, FileSource, PromptSource, YubikeySource
  |     resolve_identities() -> Vec<Box<dyn Identity>>
  |
  +-- Secret resolution system (new)
        SecretSource trait -> 11 provider adapters
        SecretResolver -> config-driven resolution with bindings

Core Traits

/// Port: resolve a single secret by key name.
pub trait SecretSource {
    fn name(&self) -> &str;
    fn provider(&self) -> Provider;
    fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;

    /// Resolve using a provider-specific typed reference.
    /// Default: ignores ref, falls back to resolve(key).
    fn resolve_ref(
        &self,
        key: &str,
        secret_ref: &SecretRef,
    ) -> Result<Option<String>, SecretsError> {
        let _ = secret_ref;
        self.resolve(key)
    }
}

/// Extended port: sources that can enumerate all available secrets.
pub trait EnumerableSecretSource: SecretSource {
    fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
}

ISP rationale: not every backend supports enumeration. OpSource and GsmSource require explicit refs for lookup -- they cannot enumerate a vault without extra config. Forcing resolve_all() on every impl would produce incomplete maps.

Type System

Provider enum

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
    Env,
    Op,
    Dotenvx,
    Sops,
    Gsm,
    Nuenv,
    Direnv,
    Mise,
    Bitwarden,
    Vault,
    Dotenvy,
}

Provider config (tagged enum)

#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ProviderConfig {
    Env,
    Op { account: String },
    Dotenvx { env_file: PathBuf },
    Sops { file: PathBuf },
    Gsm { project: String },
    Nuenv,
    Direnv,
    Mise,
    Bitwarden { server_url: Option<String> },
    Vault { addr: String, mount: Option<String> },
    Dotenvy { path: PathBuf },
}

Secret references (tagged enum)

#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum SecretRef {
    Env,
    Op { uri: String },
    Dotenvx { key: Option<String> },
    Sops { key: Option<String> },
    Gsm { path: String },
    Nuenv { key: Option<String> },
    Direnv { key: Option<String> },
    Mise { key: Option<String> },
    Bitwarden { item: String, field: Option<String> },
    Vault { path: String, field: Option<String> },
    Dotenvy { key: Option<String> },
}

impl SecretRef {
    pub fn provider(&self) -> Provider {
        match self {
            Self::Env { .. }       => Provider::Env,
            Self::Op { .. }        => Provider::Op,
            Self::Dotenvx { .. }   => Provider::Dotenvx,
            Self::Sops { .. }      => Provider::Sops,
            Self::Gsm { .. }       => Provider::Gsm,
            Self::Nuenv { .. }     => Provider::Nuenv,
            Self::Direnv { .. }    => Provider::Direnv,
            Self::Mise { .. }      => Provider::Mise,
            Self::Bitwarden { .. } => Provider::Bitwarden,
            Self::Vault { .. }     => Provider::Vault,
            Self::Dotenvy { .. }   => Provider::Dotenvy,
        }
    }
}

Config root

#[derive(Debug, Deserialize)]
pub struct SecretsConfig {
    pub providers: Vec<Provider>,
    #[serde(default)]
    pub provider: HashMap<Provider, ProviderConfig>,
    #[serde(default)]
    pub secrets: HashMap<String, SecretRef>,
}

Compile-Time Guarantees

  • Misspelled provider name in TOML -> serde deserialization error
  • Binding with wrong fields for a provider -> serde error
  • from_config validates every binding references a provider in the chain (enum comparison, not string matching)
  • resolve_ref receives typed variant; compiler warns on missing arms

Domain Error

#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
    #[error("[{name}] {source}")]
    SourceError { name: String, source: anyhow::Error },
    #[error("no provider resolved key: {key}")]
    NotFound { key: String },
    #[error("config error: {0}")]
    Config(String),
}

Adapters map infra errors (reqwest, tonic, serde, io) into SecretsError::SourceError. Domain logic never sees infra error types.

SecretResolver (domain logic)

pub struct SecretResolver {
    config: SecretsConfig,
    sources: Vec<Box<dyn SecretSource>>,
    enumerable: Vec<Box<dyn EnumerableSecretSource>>,
}

impl SecretResolver {
    pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError>;
    pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
    pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
}

resolve(key) flow

  1. Check explicit bindings (config.secrets). If key has a SecretRef, route to the named source via source.resolve_ref(key, &secret_ref).
  2. Otherwise, walk the priority chain (sources in order). First Some wins.

resolve_all() flow

  1. Iterate enumerable sources in priority order. Merge maps -- earlier source wins on key collision.
  2. Overlay explicit bindings: resolve each bound key individually via resolve_ref. Bindings always override chain results.

No inject_env()

SRP: env injection (set_var) is a side effect belonging to the consumer (notstrap), not the resolver. SecretResolver returns data; the caller decides what to do with it.

Config File

Separate notsecrets.toml at the dotfiles root. Rationale:

  • notsecrets is consumed by notstrap, notfiles, and potentially standalone
  • Provider chain config is complex enough to warrant its own file
  • Follows existing pattern (each crate has its own state file)
  • Enables standalone CLI usage: notsecrets resolve DATABASE_URL

Example

providers = ["env", "op", "dotenvx", "sops", "gsm"]

[provider.op]
type = "op"
account = "my.1password.com"

[provider.dotenvx]
type = "dotenvx"
env_file = "~/dev/.env"

[provider.sops]
type = "sops"
file = "secrets/bootstrap.sops.env"

[provider.gsm]
type = "gsm"
project = "my-gcp-project"

[secrets]
DATABASE_URL = { source = "op", uri = "op://Personal/db/url" }
ANTHROPIC_API_KEY = { source = "dotenvx" }
GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" }

File Layout

New and modified files in crates/notsecrets/src/:

src/
  lib.rs              # add re-exports for new public API
  ports.rs            # add SecretSource, EnumerableSecretSource
  config.rs           # NEW -- SecretsConfig, ProviderConfig, SecretRef, Provider, load_config()
  resolver.rs         # NEW -- SecretResolver
  error.rs            # add SecretsError alongside existing AgeError
  sources/
    mod.rs            # add new source re-exports
    env.rs            # NEW tier 1 -- SecretSource + EnumerableSecretSource
    op.rs             # NEW tier 1 -- SecretSource only
    dotenvx.rs        # NEW tier 1 -- SecretSource + EnumerableSecretSource
    sops.rs           # NEW tier 1 -- SecretSource + EnumerableSecretSource
    gsm.rs            # NEW tier 1 -- SecretSource only
    nuenv.rs          # NEW tier 2 stub
    direnv.rs         # NEW tier 2 stub
    mise.rs           # NEW tier 2 stub
    vault.rs          # NEW tier 2 stub
    dotenvy.rs        # NEW tier 2 stub
    bitwarden.rs      # existing -- add tier 2 SecretSource stub
    file.rs           # existing -- untouched
    prompt.rs         # existing -- untouched
    yubikey.rs        # existing -- untouched
  identities/         # untouched
  recipients/         # untouched
  decrypt.rs          # untouched
  encrypt.rs          # untouched
  format.rs           # untouched

Consumer Changes

notstrap

Current hardcoded source chain (lib.rs:135-143) and EnvInjector closure replaced by:

let secrets_config = notsecrets::config::load_config(
    &dotfiles_dir.join("notsecrets.toml")
)?;
let resolver = SecretResolver::from_config(secrets_config)?;
let env_map = resolver.resolve_all()?;

for (k, v) in &env_map {
    if let Some((k, v)) = parse_env_line(&format!("{k}={v}"))? {
        unsafe { std::env::set_var(&k, &v); }
    }
}
report.add("secrets", StepStatus::Ok);

EnvInjector type alias and env_injector field on BootstrapOptions become unnecessary and are removed.

Testing Strategy

Dimension Scope When
Unit Resolver routing (binding vs chain), config parsing Every new function
Conformance Shared suite for SecretSource trait, all impls Every new impl
Property Config deserialization: arbitrary TOML never panics After config types defined
Integration Resolver + real EnvSource + fake sources After resolver + 2+ sources
Regression One test per bug Ongoing
Fuzz Deferred -- no raw byte parsing or unsafe Revisit if SOPS moves in-crate
Model check Deferred -- no arithmetic invariants N/A

Conformance suite

fn assert_secret_source_contract(source: &dyn SecretSource) {
    // 1. name() returns non-empty string
    // 2. provider() matches expected variant
    // 3. resolve() for unknown key returns Ok(None), not Err
    // 4. resolve_ref() with mismatched variant returns Ok(None) or
    //    delegates to resolve()
}

fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) {
    assert_secret_source_contract(source);
    // 5. resolve_all() returns Ok(map)
    // 6. For every key in resolve_all() map,
    //    resolve(key) returns Ok(Some(same_value))
}

Implementation Tiers

Tier 1 (first milestone)

  • Types: Provider, ProviderConfig, SecretRef, SecretsConfig, SecretsError
  • Traits: SecretSource, EnumerableSecretSource
  • Resolver: SecretResolver with from_config, resolve, resolve_all
  • Sources: EnvSource, OpSource, DotenvxSource, SopsSource, GsmSource
  • Config: load_config(), notsecrets.toml parsing
  • Consumer: notstrap integration
  • Tests: unit + conformance + integration

Tier 2 (subsequent)

  • Sources: NuenvSource, DirenvSource, MiseSource, BitwardenSource (SecretSource impl), VaultSource, DotenvySource
  • Each: trait impl returning Ok(None) / empty map, passing conformance suite
  • Implemented as needed when the backend is required

Decisions Log

Decision Why Alternative rejected
Separate notsecrets.toml Decouples from notfiles/notstrap config Subsection in notfiles.toml
Two traits (ISP split) Not all backends can enumerate Single trait with resolve_all()
Typed enums over string maps Compile-time validation, exhaustive matching HashMap<String, toml::Value>
No inject_env() on resolver SRP -- side effects belong to consumer Method on SecretResolver
resolve_ref default impl Sources that don't use refs delegate to resolve(key) Per-binding source instances
Explicit bindings override chain User intent is unambiguous when they bind a key Chain-only, no bindings
SecretsError separate from AgeError Different domains, different consumers Unified error enum

Out of Scope

  • Secret write/rotation path
  • RBAC or access control on secrets
  • Caching/TTL for resolved secrets
  • Async trait (all providers are sync CLI/file-based today)
  • Migration tooling from old notstrap config format