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}