Skip to main content

devboy_storage/
source.rs

1//! `SecretSource` trait + supporting types per [ADR-021] §1.
2//!
3//! A *source* is any backend able to answer questions about secrets:
4//! the OS keychain, the encrypted local vault from ADR-023, the
5//! 1Password CLI, a HashiCorp Vault server, an env-store mount, or a
6//! community subprocess plugin. The router (epic phase P5.2 / P5.3)
7//! maps an ADR-020 path to a `(source, reference)` pair and then
8//! invokes the source's [`SecretSource::get`] / `list` / `validate`
9//! through this trait.
10//!
11//! Two design choices worth knowing:
12//!
13//! 1. **Sources do not understand ADR-020 paths.** They take an
14//!    opaque `reference: &str`. Mapping a path to a reference is the
15//!    router's job. This separation lets a source plugin be written
16//!    without any awareness of the manifest layer, which keeps the
17//!    plugin protocol (P15) tiny.
18//! 2. **Capabilities are explicit, not inferred.** A source whose
19//!    only operation is `READ` declares exactly that. The router
20//!    refuses to call `WRITE` on it without trying — the failure is
21//!    structured (`SourceError::UnsupportedCapability`) rather than
22//!    a network round-trip that returns "method not allowed".
23//!
24//! ## What this module does **not** define
25//!
26//! - The router itself (P5.2).
27//! - Any specific source impl (`keychain`, `local-vault`,
28//!   `1password`, …) — those land in P6.
29//! - The recursion check that enforces the
30//!   `__sources/<source>/<profile>` invariant — that's P5.5.
31//! - The `Capabilities`-aware in-memory cache — P5.4.
32//!
33//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
34
35use std::time::Duration;
36
37use async_trait::async_trait;
38use bitflags::bitflags;
39use secrecy::SecretString;
40use thiserror::Error;
41
42use crate::SecretPath;
43
44// =============================================================================
45// Capabilities
46// =============================================================================
47
48bitflags! {
49    /// What a source can do, plus two descriptive flags consumed by
50    /// `doctor` and the agent provisioning surface.
51    ///
52    /// Per [ADR-021] §1.1. The first five bits are *operational* —
53    /// the router refuses to dispatch an operation unless the
54    /// matching bit is set. The last two bits are *descriptive* —
55    /// they let agents and `doctor` reason about UX trade-offs
56    /// without trying the operation:
57    ///
58    /// - [`BIOMETRIC_PROMPT`](Self::BIOMETRIC_PROMPT) — the source
59    ///   *may* prompt for user-presence (TouchID, PIN, passphrase)
60    ///   on at least one of its operations in its default
61    ///   configuration. Single-bit flag on the source as a whole;
62    ///   the router does not infer per-operation cost from it.
63    /// - [`AUDIT_LOGGED`](Self::AUDIT_LOGGED) — every read is
64    ///   durably logged on the upstream (Vault audit log, 1Password
65    ///   account activity). Surfaced in `doctor` so the user knows
66    ///   their reads are observable.
67    ///
68    /// Typical declarations:
69    ///
70    /// | Source            | Capabilities                                                   |
71    /// |-------------------|----------------------------------------------------------------|
72    /// | env-store         | `READ`                                                         |
73    /// | keychain          | `READ \| LIST \| VALIDATE \| WRITE`                            |
74    /// | local-vault       | `READ \| LIST \| VALIDATE \| WRITE \| ROTATE \| BIOMETRIC_PROMPT` |
75    /// | 1password (cli)   | `READ \| LIST \| VALIDATE \| BIOMETRIC_PROMPT \| AUDIT_LOGGED` |
76    /// | vault (kv v2)     | `READ \| LIST \| VALIDATE \| WRITE \| ROTATE \| AUDIT_LOGGED`   |
77    ///
78    /// [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
79    #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
80    pub struct Capabilities: u32 {
81        /// The source can answer `get(reference)`.
82        const READ              = 0b0000_0001;
83        /// The source can enumerate its inventory via `list()`.
84        const LIST              = 0b0000_0010;
85        /// The source can validate that a reference is well-formed
86        /// without round-tripping for the value.
87        const VALIDATE          = 0b0000_0100;
88        /// The source can write a new value at `reference`.
89        const WRITE             = 0b0000_1000;
90        /// The source can rotate (replace + invalidate prior) a
91        /// value at `reference`.
92        const ROTATE            = 0b0001_0000;
93        /// Descriptive — the source may prompt the user for
94        /// biometrics / a PIN / a passphrase on at least one of
95        /// its operations.
96        const BIOMETRIC_PROMPT  = 0b0010_0000;
97        /// Descriptive — every read is observable in the upstream's
98        /// audit log.
99        const AUDIT_LOGGED      = 0b0100_0000;
100    }
101}
102
103// =============================================================================
104// Source-credential reference
105// =============================================================================
106
107/// What a source needs to be operational.
108///
109/// Per [ADR-021] §4: a Vault source needs a Vault token, a 1Password
110/// source needs a signed-in account session, etc. The credential
111/// reference is *either*:
112///
113/// - A path under the reserved `__sources/` namespace pointing at
114///   the credential value in the credential store, or
115/// - A source-defined *sentinel string* (`biometric`,
116///   `default-profile`, `oauth`) that the source plugin interprets
117///   itself.
118///
119/// The router uses this on configuration load to enforce the
120/// recursion invariant (P5.5): a source-credential must resolve
121/// through a source whose own [`SecretSource::requires_credential`]
122/// is `None`. That's why this enum exists — sources need a way to
123/// say "my credential lives at this path" *or* "my credential lives
124/// inside me; ask my native unlock". Without the distinction the
125/// recursion check would reject 1Password's biometric session as a
126/// missing credential.
127///
128/// [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum CredentialRef {
131    /// A path under the reserved `__sources/` namespace. The router
132    /// fetches the value from the credential store and hands it to
133    /// the source's init step.
134    Path(SecretPath),
135    /// A source-specific sentinel like `biometric` or
136    /// `default-profile`. Opaque to the router; meaningful only to
137    /// the source plugin.
138    Sentinel(String),
139}
140
141// =============================================================================
142// Source status
143// =============================================================================
144
145/// Snapshot of a source's connectivity at the moment of the call.
146///
147/// Returned by [`SecretSource::is_available`]. The router uses it
148/// to decide whether to retry the default route against a fallback
149/// (per ADR-021 §2 step 3) and `doctor` uses it to render the
150/// per-source health check.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum SourceStatus {
153    /// Backend is reachable and ready to answer queries.
154    Available,
155    /// Backend is reachable but needs an unlock step before use
156    /// (e.g. local-vault is locked, 1Password account is signed
157    /// out).
158    Locked,
159    /// The CLI / SDK / system service this source depends on is
160    /// not present at all (e.g. `op` not installed).
161    NotInstalled,
162    /// Anything else — surfaced verbatim to `doctor`.
163    Error(String),
164}
165
166impl SourceStatus {
167    /// Convenience — `true` only when the source is fully
168    /// operational. Treats `Locked` / `NotInstalled` / `Error` as
169    /// not-available.
170    pub fn is_available(&self) -> bool {
171        matches!(self, SourceStatus::Available)
172    }
173}
174
175// =============================================================================
176// Get outcome
177// =============================================================================
178
179/// Successful payload of [`SecretSource::get`].
180///
181/// `value` is wrapped in [`SecretString`] so the plaintext doesn't
182/// accidentally land in a `Debug` print or a log line. Only the
183/// final consumer (an HTTP header builder, an SDK call site)
184/// should call `expose_secret()`.
185///
186/// `lease_duration` lets the router's adaptive-TTL cache (P5.4)
187/// shrink its TTL to fit short-lived dynamic secrets — Vault's
188/// dynamic database creds, AWS STS tokens, etc. Sources that don't
189/// have a lease concept return `None`, in which case the cache uses
190/// its default TTL.
191#[derive(Debug)]
192pub struct GetOutcome {
193    /// The secret value.
194    pub value: SecretString,
195    /// Upstream-reported lease duration, if any. The router caps
196    /// the cache TTL at this value (per ADR-021 §7).
197    pub lease_duration: Option<Duration>,
198}
199
200// =============================================================================
201// List entry
202// =============================================================================
203
204/// One entry returned by [`SecretSource::list`].
205///
206/// `reference` is the backend-specific opaque string the router
207/// would feed back into `get()`. `display` is the human-readable
208/// label the source can offer when one is available — TUI
209/// discovery (P11.4) renders it in the picker.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct RemoteRef {
212    /// Backend-specific reference string.
213    pub reference: String,
214    /// Optional human-readable label (1Password item title, Vault
215    /// path, env-var name, etc.). When absent, callers fall back to
216    /// `reference`.
217    pub display: Option<String>,
218}
219
220// =============================================================================
221// Errors
222// =============================================================================
223
224/// All the ways a source operation can fail.
225#[derive(Debug, Error)]
226pub enum SourceError {
227    /// The source's [`SecretSource::is_available`] check returned
228    /// something other than `Available`. Caller may retry against a
229    /// fallback or surface the underlying status.
230    #[error("source `{name}` is not available: {reason}")]
231    Unavailable {
232        /// Source name (matches [`SecretSource::name`]).
233        name: String,
234        /// Human-readable detail — typically the `Display` of the
235        /// underlying [`SourceStatus`] variant.
236        reason: String,
237    },
238
239    /// The router asked the source to do something its
240    /// [`SecretSource::capabilities`] said it could not.
241    #[error("source `{name}` does not support {capability:?}")]
242    UnsupportedCapability {
243        /// Source name.
244        name: String,
245        /// The capability that was missing.
246        capability: Capabilities,
247    },
248
249    /// The reference string isn't well-formed for this backend
250    /// (e.g. malformed `op://` URL, invalid Vault path).
251    #[error("source `{name}` rejected reference `{reference}`: {reason}")]
252    BadReference {
253        /// Source name.
254        name: String,
255        /// The reference the source rejected.
256        reference: String,
257        /// Human-readable detail.
258        reason: String,
259    },
260
261    /// The source itself errored (network failure, upstream
262    /// returned 5xx, plugin subprocess crashed, …).
263    #[error("source `{name}` upstream error: {message}")]
264    Upstream {
265        /// Source name.
266        name: String,
267        /// Human-readable detail.
268        message: String,
269    },
270
271    /// The source needs an unlock step before it can answer this
272    /// operation. Typically maps to [`SourceStatus::Locked`].
273    #[error("source `{name}` requires unlock")]
274    Locked {
275        /// Source name.
276        name: String,
277    },
278
279    /// I/O error talking to the source (mostly subprocess stdio /
280    /// socket I/O for plugin-style sources).
281    #[error("I/O error talking to source `{name}`")]
282    Io {
283        /// Source name.
284        name: String,
285        /// Underlying I/O error.
286        #[source]
287        source: std::io::Error,
288    },
289}
290
291// =============================================================================
292// SecretSource trait
293// =============================================================================
294
295/// Backend-agnostic interface for any "place secrets live."
296///
297/// Per [ADR-021] §1. Implementations are sync-Sendable so they can
298/// be stored in a `Box<dyn SecretSource>` and shared across tasks.
299/// Operations are `async` so HTTP-backed sources (Vault, future
300/// cloud KMS) don't have to block; the local sources
301/// (`keychain`, `local-vault`, `env-store`) await trivially.
302///
303/// ## Default-method behaviour
304///
305/// `list` and `validate` default to returning
306/// [`SourceError::UnsupportedCapability`]. A source that declares
307/// the matching capability bit *must* override the corresponding
308/// method, or the router's invariant ("declared cap → call works")
309/// breaks. (A debug-build assertion in P5.3 will catch the
310/// mismatch.)
311///
312/// `requires_credential` defaults to `None`, which is what most
313/// sources want — only Vault, 1Password, AWS, and friends override.
314///
315/// [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
316#[async_trait]
317pub trait SecretSource: Send + Sync {
318    /// Stable identifier of this source instance. Matches the
319    /// `name` field of `[[source]]` in `sources.toml`.
320    fn name(&self) -> &str;
321
322    /// Static capability bitset for this source. Cheap; called by
323    /// the router on every dispatch so do not allocate.
324    fn capabilities(&self) -> Capabilities;
325
326    /// What the source itself needs to be unlocked / operational.
327    /// `None` for self-contained sources (keychain, env-store).
328    /// `Some(Path(_))` or `Some(Sentinel(_))` for sources that
329    /// depend on something the framework has to provide first
330    /// (Vault token, 1Password account session).
331    fn requires_credential(&self) -> Option<CredentialRef> {
332        None
333    }
334
335    /// Probe the backend. Cheap when the source caches the answer;
336    /// otherwise this might shell out (`op account list`) or open a
337    /// short-lived connection. Used by the router's fallback logic
338    /// (ADR-021 §2 step 3) and by `doctor`.
339    async fn is_available(&self) -> SourceStatus;
340
341    /// Fetch the value at `reference`. `Ok(None)` means "the source
342    /// is operational but has no entry there"; an error means the
343    /// source itself failed to talk to its upstream.
344    async fn get(&self, reference: &str) -> Result<Option<GetOutcome>, SourceError>;
345
346    /// Enumerate the source's inventory. Default implementation
347    /// reports unsupported. A source that declares
348    /// [`Capabilities::LIST`] must override.
349    async fn list(&self) -> Result<Vec<RemoteRef>, SourceError> {
350        Err(SourceError::UnsupportedCapability {
351            name: self.name().to_owned(),
352            capability: Capabilities::LIST,
353        })
354    }
355
356    /// Confirm `reference` is well-formed for this backend without
357    /// round-tripping for the value (e.g. parse the `op://` URL,
358    /// check the Vault path syntax). Default implementation reports
359    /// unsupported.
360    async fn validate(&self, reference: &str) -> Result<(), SourceError> {
361        let _ = reference;
362        Err(SourceError::UnsupportedCapability {
363            name: self.name().to_owned(),
364            capability: Capabilities::VALIDATE,
365        })
366    }
367}
368
369// =============================================================================
370// Tests
371// =============================================================================
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use secrecy::ExposeSecret;
377
378    // -- Capabilities -----------------------------------------------------
379
380    #[test]
381    fn capabilities_bit_values_match_adr_021() {
382        // The exact bit positions are part of the on-the-wire
383        // contract for the future plugin protocol (P15). Pin them
384        // so a refactor doesn't accidentally renumber.
385        assert_eq!(Capabilities::READ.bits(), 0b0000_0001);
386        assert_eq!(Capabilities::LIST.bits(), 0b0000_0010);
387        assert_eq!(Capabilities::VALIDATE.bits(), 0b0000_0100);
388        assert_eq!(Capabilities::WRITE.bits(), 0b0000_1000);
389        assert_eq!(Capabilities::ROTATE.bits(), 0b0001_0000);
390        assert_eq!(Capabilities::BIOMETRIC_PROMPT.bits(), 0b0010_0000);
391        assert_eq!(Capabilities::AUDIT_LOGGED.bits(), 0b0100_0000);
392    }
393
394    #[test]
395    fn capabilities_compose_via_bitor() {
396        let read_only = Capabilities::READ | Capabilities::LIST | Capabilities::VALIDATE;
397        assert!(read_only.contains(Capabilities::READ));
398        assert!(read_only.contains(Capabilities::LIST));
399        assert!(read_only.contains(Capabilities::VALIDATE));
400        assert!(!read_only.contains(Capabilities::WRITE));
401        assert!(!read_only.contains(Capabilities::ROTATE));
402    }
403
404    #[test]
405    fn capabilities_default_is_empty() {
406        let empty: Capabilities = Capabilities::default();
407        assert_eq!(empty.bits(), 0);
408        assert!(empty.is_empty());
409    }
410
411    // -- SourceStatus -----------------------------------------------------
412
413    #[test]
414    fn source_status_is_available_only_for_available_variant() {
415        assert!(SourceStatus::Available.is_available());
416        assert!(!SourceStatus::Locked.is_available());
417        assert!(!SourceStatus::NotInstalled.is_available());
418        assert!(!SourceStatus::Error("disk full".into()).is_available());
419    }
420
421    // -- CredentialRef ----------------------------------------------------
422
423    #[test]
424    fn credential_ref_can_be_a_sources_path() {
425        let path = SecretPath::parse_internal("__sources/vault-team/deploy").unwrap();
426        let cr = CredentialRef::Path(path.clone());
427        match cr {
428            CredentialRef::Path(p) => assert_eq!(p, path),
429            CredentialRef::Sentinel(_) => panic!("wrong variant"),
430        }
431    }
432
433    #[test]
434    fn credential_ref_can_be_a_sentinel() {
435        let cr = CredentialRef::Sentinel("biometric".into());
436        match cr {
437            CredentialRef::Sentinel(s) => assert_eq!(s, "biometric"),
438            CredentialRef::Path(_) => panic!("wrong variant"),
439        }
440    }
441
442    // -- Default trait methods -------------------------------------------
443
444    /// Bare-minimum source: only declares READ. Used to verify that
445    /// the default `list` / `validate` impls bail with a structured
446    /// error and that `requires_credential` defaults to `None`.
447    struct ReadOnlySource;
448
449    #[async_trait]
450    impl SecretSource for ReadOnlySource {
451        fn name(&self) -> &str {
452            "read-only-fixture"
453        }
454        fn capabilities(&self) -> Capabilities {
455            Capabilities::READ
456        }
457        async fn is_available(&self) -> SourceStatus {
458            SourceStatus::Available
459        }
460        async fn get(&self, _reference: &str) -> Result<Option<GetOutcome>, SourceError> {
461            Ok(Some(GetOutcome {
462                value: SecretString::from("hunter2"),
463                lease_duration: None,
464            }))
465        }
466    }
467
468    #[tokio::test]
469    async fn default_list_reports_unsupported_capability() {
470        let s = ReadOnlySource;
471        let err = s.list().await.unwrap_err();
472        match err {
473            SourceError::UnsupportedCapability { name, capability } => {
474                assert_eq!(name, "read-only-fixture");
475                assert_eq!(capability, Capabilities::LIST);
476            }
477            other => panic!("expected UnsupportedCapability, got {other:?}"),
478        }
479    }
480
481    #[tokio::test]
482    async fn default_validate_reports_unsupported_capability() {
483        let s = ReadOnlySource;
484        let err = s.validate("anything").await.unwrap_err();
485        match err {
486            SourceError::UnsupportedCapability { name, capability } => {
487                assert_eq!(name, "read-only-fixture");
488                assert_eq!(capability, Capabilities::VALIDATE);
489            }
490            other => panic!("expected UnsupportedCapability, got {other:?}"),
491        }
492    }
493
494    #[tokio::test]
495    async fn default_requires_credential_returns_none() {
496        let s = ReadOnlySource;
497        assert!(s.requires_credential().is_none());
498    }
499
500    // -- dyn-trait usage --------------------------------------------------
501
502    #[tokio::test]
503    async fn source_works_through_dyn_box() {
504        // The router (P5.2) will store sources in a
505        // `HashMap<String, Box<dyn SecretSource>>`, so the trait
506        // must be dyn-compatible. Smoke-test that here.
507        let s: Box<dyn SecretSource> = Box::new(ReadOnlySource);
508        assert_eq!(s.name(), "read-only-fixture");
509        assert_eq!(s.capabilities(), Capabilities::READ);
510        let status = s.is_available().await;
511        assert!(status.is_available());
512        let out = s.get("ignored").await.unwrap().unwrap();
513        assert_eq!(out.value.expose_secret(), "hunter2");
514        assert!(out.lease_duration.is_none());
515    }
516
517    // -- SourceError display ---------------------------------------------
518
519    #[test]
520    fn source_error_display_includes_name_and_context() {
521        let e = SourceError::UnsupportedCapability {
522            name: "vault-team".into(),
523            capability: Capabilities::WRITE,
524        };
525        let s = format!("{e}");
526        assert!(s.contains("vault-team"));
527        assert!(s.contains("WRITE"));
528    }
529
530    #[test]
531    fn source_error_io_preserves_underlying() {
532        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "ECONNREFUSED");
533        let e = SourceError::Io {
534            name: "plugin".into(),
535            source: io,
536        };
537        // The std::io::Error message is exposed via the `#[source]`
538        // chain rather than the top-level Display, so verify both
539        // pieces.
540        assert!(format!("{e}").contains("plugin"));
541        let chained = std::error::Error::source(&e).unwrap().to_string();
542        assert!(chained.contains("ECONNREFUSED"));
543    }
544}