Skip to main content

harn_vm/secrets/
mod.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::future::Future;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use zeroize::{Zeroize, Zeroizing};
10
11mod env;
12mod file;
13mod keyring;
14mod memory;
15
16pub use env::EnvSecretProvider;
17pub use file::{FileSecretProvider, SECRET_FILE_PATH_ENV};
18pub use keyring::{
19    KeyringSecretProvider, NativeKeyring, NativeKeyringError, NativeKeyringUnavailable,
20};
21pub use memory::MemorySecretProvider;
22
23pub const DEFAULT_SECRET_PROVIDER_CHAIN: &str = "env,keyring";
24pub const SECRET_PROVIDER_CHAIN_ENV: &str = "HARN_SECRET_PROVIDERS";
25pub const SECRET_REF_SCHEME: &str = "harn-secret://";
26/// The one keyring namespace every Harn surface stores and resolves
27/// credentials under. `harn connect`, `--grant secret://`, connector dispatch,
28/// and `harn doctor` all read this same service so a credential that is
29/// storable is also usable.
30pub const DEFAULT_SECRET_NAMESPACE: &str = "harn.provider_auth";
31pub const SECRET_NAMESPACE_ENV: &str = "HARN_SECRET_NAMESPACE";
32pub const CONNECTOR_OAUTH_TOKEN_SECRET_NAME: &str = "oauth-token";
33pub const CONNECTOR_ACCESS_TOKEN_SECRET_NAME: &str = "access-token";
34pub const CONNECTOR_REFRESH_TOKEN_SECRET_NAME: &str = "refresh-token";
35const RUNTIME_PROVENANCE_SECRET_NAMESPACE: &str = "provenance";
36const SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE: &str = "harn.provenance";
37
38tokio::task_local! {
39    static ACTIVE_SECRET_PROVIDER: Arc<dyn SecretProvider>;
40}
41
42#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
43pub enum SecretVersion {
44    #[default]
45    Latest,
46    Exact(u64),
47}
48
49#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
50pub struct SecretId {
51    pub namespace: String,
52    pub name: String,
53    #[serde(default)]
54    pub version: SecretVersion,
55}
56
57impl SecretId {
58    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
59        Self {
60            namespace: namespace.into(),
61            name: name.into(),
62            version: SecretVersion::Latest,
63        }
64    }
65
66    pub fn with_version(mut self, version: SecretVersion) -> Self {
67        self.version = version;
68        self
69    }
70}
71
72impl fmt::Display for SecretId {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        if self.namespace.is_empty() {
75            write!(f, "{}", self.name)?;
76        } else {
77            write!(f, "{}/{}", self.namespace, self.name)?;
78        }
79        match self.version {
80            SecretVersion::Latest => Ok(()),
81            SecretVersion::Exact(version) => write!(f, "@{version}"),
82        }
83    }
84}
85
86pub fn parse_secret_ref(raw: &str) -> Result<Option<SecretId>, SecretError> {
87    let trimmed = raw.trim();
88    let Some(rest) = trimmed.strip_prefix(SECRET_REF_SCHEME) else {
89        return Ok(None);
90    };
91    let (base, version) = match rest.rsplit_once('@') {
92        Some((base, version_text)) => {
93            let version = version_text.parse::<u64>().map_err(|_| {
94                SecretError::InvalidInput(format!(
95                    "invalid secret reference version in '{trimmed}'"
96                ))
97            })?;
98            (base, SecretVersion::Exact(version))
99        }
100        None => (rest, SecretVersion::Latest),
101    };
102    let (namespace, name) = base.split_once('/').ok_or_else(|| {
103        SecretError::InvalidInput(format!(
104            "invalid secret reference '{trimmed}': expected {SECRET_REF_SCHEME}<namespace>/<name>"
105        ))
106    })?;
107    if namespace.trim().is_empty() || name.trim().is_empty() {
108        return Err(SecretError::InvalidInput(format!(
109            "invalid secret reference '{trimmed}': namespace and name must be non-empty"
110        )));
111    }
112    Ok(Some(
113        SecretId::new(namespace.trim(), name.trim()).with_version(version),
114    ))
115}
116
117pub fn parse_secret_id(raw: &str) -> Result<SecretId, SecretError> {
118    if let Some(id) = parse_secret_ref(raw)? {
119        return Ok(id);
120    }
121    parse_secret_id_body(raw.trim(), raw)
122}
123
124fn parse_secret_id_body(body: &str, original: &str) -> Result<SecretId, SecretError> {
125    let (base, version) = match body.rsplit_once('@') {
126        Some((base, version_text)) => {
127            let version = version_text.parse::<u64>().map_err(|_| {
128                SecretError::InvalidInput(format!("invalid secret id version in '{original}'"))
129            })?;
130            (base, SecretVersion::Exact(version))
131        }
132        None => (body, SecretVersion::Latest),
133    };
134    let (namespace, name) = base.split_once('/').ok_or_else(|| {
135        SecretError::InvalidInput(format!(
136            "invalid secret id '{original}': expected <namespace>/<name>"
137        ))
138    })?;
139    if namespace.trim().is_empty() || name.trim().is_empty() {
140        return Err(SecretError::InvalidInput(format!(
141            "invalid secret id '{original}': namespace and name must be non-empty"
142        )));
143    }
144    Ok(SecretId::new(namespace.trim(), name.trim()).with_version(version))
145}
146
147pub fn connector_oauth_token_id(provider: &str) -> SecretId {
148    SecretId::new(provider, CONNECTOR_OAUTH_TOKEN_SECRET_NAME)
149}
150
151pub fn connector_access_token_id(provider: &str) -> SecretId {
152    SecretId::new(provider, CONNECTOR_ACCESS_TOKEN_SECRET_NAME)
153}
154
155pub fn connector_refresh_token_id(provider: &str) -> SecretId {
156    SecretId::new(provider, CONNECTOR_REFRESH_TOKEN_SECRET_NAME)
157}
158
159pub fn resolve_secret_ref_to_string(raw: &str) -> Result<Option<String>, SecretError> {
160    let Some(id) = parse_secret_ref(raw)? else {
161        return Ok(None);
162    };
163    let secret = if let Ok(provider) = ACTIVE_SECRET_PROVIDER.try_with(Arc::clone) {
164        futures::executor::block_on(provider.get(&id))?
165    } else {
166        let chain = configured_secret_chain()?;
167        futures::executor::block_on(chain.get(&id))?
168    };
169    let rendered = secret.with_exposed(|bytes| {
170        std::str::from_utf8(bytes)
171            .map(str::to_string)
172            .map_err(|error| {
173                SecretError::InvalidInput(format!(
174                    "secret reference '{id}' resolved to non-UTF-8 bytes: {error}"
175                ))
176            })
177    })?;
178    Ok(Some(rendered))
179}
180
181/// Resolve secret references through `provider` for one async operation.
182///
183/// Hosts scope the complete session execution so model discovery, routing,
184/// health checks, and provider calls share one credential view. Tokio
185/// task-local scope keeps concurrent sessions on a shared runtime isolated.
186pub async fn with_active_secret_provider<T>(
187    provider: Option<Arc<dyn SecretProvider>>,
188    operation: impl Future<Output = T>,
189) -> T {
190    match provider {
191        Some(provider) => ACTIVE_SECRET_PROVIDER.scope(provider, operation).await,
192        None => operation.await,
193    }
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
197pub struct SecretMeta {
198    pub id: SecretId,
199    pub provider: String,
200}
201
202#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
203pub struct RotationHandle {
204    pub provider: String,
205    pub id: SecretId,
206    pub from_version: Option<u64>,
207    pub to_version: Option<u64>,
208}
209
210#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum SecretScope {
213    Tenant { id: Option<String> },
214    Workspace { id: String },
215    System,
216    Custom { kind: String, id: Option<String> },
217}
218
219impl Default for SecretScope {
220    fn default() -> Self {
221        Self::Tenant { id: None }
222    }
223}
224
225impl SecretScope {
226    pub fn tenant(id: Option<String>) -> Self {
227        Self::Tenant { id }
228    }
229
230    pub fn workspace(id: impl Into<String>) -> Self {
231        Self::Workspace { id: id.into() }
232    }
233
234    pub fn system() -> Self {
235        Self::System
236    }
237
238    pub fn custom(kind: impl Into<String>, id: Option<String>) -> Self {
239        Self::Custom {
240            kind: kind.into(),
241            id,
242        }
243    }
244
245    pub fn namespace(&self) -> String {
246        match self {
247            Self::Tenant { id: Some(id) } if !id.is_empty() => format!("harn.tenant.{id}"),
248            Self::Tenant { .. } => "harn.tenant".to_string(),
249            Self::Workspace { id } => format!("harn.workspace.{id}"),
250            Self::System => "harn.system".to_string(),
251            Self::Custom { kind, id: Some(id) } if !id.is_empty() => {
252                format!("harn.{kind}.{id}")
253            }
254            Self::Custom { kind, .. } => format!("harn.{kind}"),
255        }
256    }
257
258    pub fn kind(&self) -> &str {
259        match self {
260            Self::Tenant { .. } => "tenant",
261            Self::Workspace { .. } => "workspace",
262            Self::System => "system",
263            Self::Custom { kind, .. } => kind.as_str(),
264        }
265    }
266
267    pub fn id(&self) -> Option<&str> {
268        match self {
269            Self::Tenant { id } | Self::Custom { id, .. } => id.as_deref(),
270            Self::Workspace { id } => Some(id.as_str()),
271            Self::System => None,
272        }
273    }
274}
275
276#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
277pub struct SecretWriteOptions {
278    pub ttl: Option<Duration>,
279}
280
281#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
282pub struct SecretRotationOptions {
283    pub grace: Option<Duration>,
284    pub ttl: Option<Duration>,
285}
286
287#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
288pub struct SecretAuditContext {
289    pub request_id: Option<String>,
290    pub actor_subject: Option<String>,
291    pub actor_kind: Option<String>,
292}
293
294#[derive(Debug)]
295pub struct SecretReadRequest {
296    pub id: SecretId,
297    pub scope: SecretScope,
298    pub audit: SecretAuditContext,
299}
300
301#[derive(Debug)]
302pub struct SecretDeleteRequest {
303    pub id: SecretId,
304    pub scope: SecretScope,
305    pub audit: SecretAuditContext,
306}
307
308#[derive(Debug)]
309pub struct SecretWriteRequest {
310    pub id: SecretId,
311    pub scope: SecretScope,
312    pub value: SecretBytes,
313    pub options: SecretWriteOptions,
314    pub audit: SecretAuditContext,
315}
316
317#[derive(Debug)]
318pub struct SecretRotateRequest {
319    pub id: SecretId,
320    pub scope: SecretScope,
321    pub value: SecretBytes,
322    pub options: SecretRotationOptions,
323    pub audit: SecretAuditContext,
324}
325
326#[derive(Debug)]
327pub struct SecretLeaseRequest {
328    pub id: SecretId,
329    pub scope: SecretScope,
330    pub duration: Duration,
331    pub audit: SecretAuditContext,
332}
333
334#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
335pub struct SecretWriteReceipt {
336    pub provider: String,
337    pub id: SecretId,
338    pub scope: SecretScope,
339    pub version: Option<u64>,
340    pub expires_at_unix_ms: Option<i64>,
341}
342
343#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
344pub struct SecretRotationReceipt {
345    pub provider: String,
346    pub id: SecretId,
347    pub scope: SecretScope,
348    pub from_version: Option<u64>,
349    pub to_version: Option<u64>,
350    pub grace_until_unix_ms: Option<i64>,
351    pub expires_at_unix_ms: Option<i64>,
352}
353
354#[derive(Debug)]
355pub struct SecretLeaseGrant {
356    pub provider: String,
357    pub id: SecretId,
358    pub scope: SecretScope,
359    pub lease_id: String,
360    pub value: SecretBytes,
361    pub expires_at_unix_ms: i64,
362}
363
364#[derive(Clone, Debug, Eq, PartialEq)]
365pub enum SecretError {
366    NotFound {
367        provider: String,
368        id: SecretId,
369    },
370    Unsupported {
371        provider: String,
372        operation: &'static str,
373    },
374    Backend {
375        provider: String,
376        message: String,
377    },
378    AccessDenied {
379        operation: String,
380        id: SecretId,
381        message: String,
382    },
383    InvalidConfig(String),
384    InvalidInput(String),
385    NoProviders {
386        namespace: String,
387    },
388    All(Vec<SecretError>),
389}
390
391impl SecretError {
392    /// Whether this error means "the store does not hold it" rather than "the
393    /// store could not answer".
394    ///
395    /// The distinction decides a caller's policy — a missing credential is a
396    /// setup step, an unreachable backend is an outage — so it lives on the
397    /// error rather than being re-derived by each caller. A chain reports
398    /// absence only when every provider in it reported absence.
399    pub fn is_not_found(&self) -> bool {
400        match self {
401            Self::NotFound { .. } => true,
402            Self::All(errors) => !errors.is_empty() && errors.iter().all(Self::is_not_found),
403            _ => false,
404        }
405    }
406}
407
408impl fmt::Display for SecretError {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        match self {
411            Self::NotFound { provider, id } => {
412                write!(f, "{provider}: secret '{id}' not found")
413            }
414            Self::Unsupported {
415                provider,
416                operation,
417            } => write!(f, "{provider}: operation '{operation}' is unsupported"),
418            Self::Backend { provider, message } => write!(f, "{provider}: {message}"),
419            Self::AccessDenied {
420                operation,
421                id,
422                message,
423            } => write!(f, "secret {operation} denied for '{id}': {message}"),
424            Self::InvalidConfig(message) => write!(f, "{message}"),
425            Self::InvalidInput(message) => write!(f, "{message}"),
426            Self::NoProviders { namespace } => {
427                write!(
428                    f,
429                    "no secret providers configured for namespace '{namespace}'"
430                )
431            }
432            Self::All(errors) => {
433                let rendered = errors
434                    .iter()
435                    .map(ToString::to_string)
436                    .collect::<Vec<_>>()
437                    .join("; ");
438                write!(f, "all secret providers failed: {rendered}")
439            }
440        }
441    }
442}
443
444impl std::error::Error for SecretError {}
445
446#[derive(Default)]
447struct SecretBuffer {
448    bytes: Vec<u8>,
449    #[cfg(test)]
450    drop_probe: Option<std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>>,
451}
452
453impl SecretBuffer {
454    fn new(bytes: Vec<u8>) -> Self {
455        Self {
456            bytes,
457            #[cfg(test)]
458            drop_probe: None,
459        }
460    }
461
462    fn as_slice(&self) -> &[u8] {
463        &self.bytes
464    }
465
466    #[cfg(test)]
467    fn attach_drop_probe(&mut self, probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>) {
468        self.drop_probe = Some(probe);
469    }
470}
471
472impl std::ops::Deref for SecretBuffer {
473    type Target = [u8];
474
475    fn deref(&self) -> &Self::Target {
476        self.as_slice()
477    }
478}
479
480impl Zeroize for SecretBuffer {
481    fn zeroize(&mut self) {
482        self.bytes.zeroize();
483    }
484}
485
486impl Drop for SecretBuffer {
487    fn drop(&mut self) {
488        #[cfg(test)]
489        if let Some(probe) = &self.drop_probe {
490            *probe.lock().expect("drop probe poisoned") = Some(self.bytes.clone());
491        }
492    }
493}
494
495pub struct SecretBytes(Zeroizing<SecretBuffer>);
496
497impl SecretBytes {
498    pub fn new(bytes: Vec<u8>) -> Self {
499        Self(Zeroizing::new(SecretBuffer::new(bytes)))
500    }
501
502    pub fn len(&self) -> usize {
503        self.0.as_slice().len()
504    }
505
506    pub fn is_empty(&self) -> bool {
507        self.0.as_slice().is_empty()
508    }
509
510    pub fn with_exposed<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
511        f(self.0.as_slice())
512    }
513
514    pub fn reborrow(&self) -> Self {
515        self.with_exposed(|bytes| Self::new(bytes.to_vec()))
516    }
517
518    #[cfg(test)]
519    pub(crate) fn attach_drop_probe(
520        &mut self,
521        probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>,
522    ) {
523        self.0.attach_drop_probe(probe);
524    }
525}
526
527impl fmt::Debug for SecretBytes {
528    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529        write!(f, "SecretBytes {{ redacted: {} bytes }}", self.len())
530    }
531}
532
533impl Serialize for SecretBytes {
534    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
535    where
536        S: serde::Serializer,
537    {
538        serializer.serialize_str(&format!("<redacted:{} bytes>", self.len()))
539    }
540}
541
542impl From<Vec<u8>> for SecretBytes {
543    fn from(value: Vec<u8>) -> Self {
544        Self::new(value)
545    }
546}
547
548impl From<String> for SecretBytes {
549    fn from(value: String) -> Self {
550        Self::new(value.into_bytes())
551    }
552}
553
554impl From<&str> for SecretBytes {
555    fn from(value: &str) -> Self {
556        Self::new(value.as_bytes().to_vec())
557    }
558}
559
560impl From<&[u8]> for SecretBytes {
561    fn from(value: &[u8]) -> Self {
562        Self::new(value.to_vec())
563    }
564}
565
566#[async_trait]
567pub trait SecretProvider: Send + Sync {
568    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError>;
569    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError>;
570    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError>;
571    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError>;
572
573    /// Whether this provider holds `id`, without reading its value.
574    ///
575    /// Presence and value are the same question for most backends and a very
576    /// different one for an operating-system keychain: on macOS, reading an
577    /// item's data raises an access dialog for any binary not already on that
578    /// item's ACL, while enumerating item attributes raises none. Callers that
579    /// only need a yes or no — `connect status` deciding whether a connector is
580    /// usable, a doctor check — ask this so a machine with many stored
581    /// credentials does not answer one status command with a burst of dialogs.
582    ///
583    /// The default reads the value, which is correct wherever the two cost the
584    /// same. Backends where they differ override it.
585    async fn contains(&self, id: &SecretId) -> Result<bool, SecretError> {
586        match self.get(id).await {
587            Ok(_) => Ok(true),
588            Err(error) if error.is_not_found() => Ok(false),
589            Err(error) => Err(error),
590        }
591    }
592
593    async fn read_scoped(&self, request: SecretReadRequest) -> Result<SecretBytes, SecretError> {
594        ensure_scoped_secret_access_allowed("read", &request.id)?;
595        self.get(&request.id).await
596    }
597
598    async fn write_scoped(
599        &self,
600        request: SecretWriteRequest,
601    ) -> Result<SecretWriteReceipt, SecretError> {
602        ensure_scoped_secret_access_allowed("write", &request.id)?;
603        if request.options.ttl.is_some() {
604            return Err(SecretError::Unsupported {
605                provider: self.namespace().to_string(),
606                operation: "write_ttl",
607            });
608        }
609        self.put(&request.id, request.value).await?;
610        Ok(SecretWriteReceipt {
611            provider: self.namespace().to_string(),
612            id: request.id,
613            scope: request.scope,
614            version: None,
615            expires_at_unix_ms: None,
616        })
617    }
618
619    async fn delete_scoped(&self, request: SecretDeleteRequest) -> Result<(), SecretError> {
620        ensure_scoped_secret_access_allowed("delete", &request.id)?;
621        let _ = request;
622        Err(SecretError::Unsupported {
623            provider: self.namespace().to_string(),
624            operation: "delete",
625        })
626    }
627
628    async fn rotate_scoped(
629        &self,
630        request: SecretRotateRequest,
631    ) -> Result<SecretRotationReceipt, SecretError> {
632        ensure_scoped_secret_access_allowed("rotate", &request.id)?;
633        let _ = request;
634        Err(SecretError::Unsupported {
635            provider: self.namespace().to_string(),
636            operation: "rotate_to",
637        })
638    }
639
640    async fn lease_scoped(
641        &self,
642        request: SecretLeaseRequest,
643    ) -> Result<SecretLeaseGrant, SecretError> {
644        ensure_scoped_secret_access_allowed("lease", &request.id)?;
645        let _ = request;
646        Err(SecretError::Unsupported {
647            provider: self.namespace().to_string(),
648            operation: "lease",
649        })
650    }
651
652    fn namespace(&self) -> &str;
653    fn supports_versions(&self) -> bool;
654}
655
656pub fn ensure_scoped_secret_access_allowed(
657    operation: impl Into<String>,
658    id: &SecretId,
659) -> Result<(), SecretError> {
660    if is_runtime_reserved_secret_namespace(&id.namespace) {
661        return Err(SecretError::AccessDenied {
662            operation: operation.into(),
663            id: id.clone(),
664            message: format!(
665                "namespace `{}` is reserved for Harn runtime provenance signing and is not accessible through agent-scoped secret APIs",
666                id.namespace
667            ),
668        });
669    }
670    Ok(())
671}
672
673pub fn is_runtime_reserved_secret_namespace(namespace: &str) -> bool {
674    let namespace = namespace.trim_matches('.');
675    namespace == RUNTIME_PROVENANCE_SECRET_NAMESPACE
676        || namespace == SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE
677        || namespace
678            .strip_prefix(SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE)
679            .is_some_and(|suffix| suffix.starts_with('.'))
680}
681
682pub struct ChainSecretProvider {
683    namespace: String,
684    providers: Vec<Arc<dyn SecretProvider>>,
685}
686
687impl ChainSecretProvider {
688    pub fn new(namespace: impl Into<String>, providers: Vec<Arc<dyn SecretProvider>>) -> Self {
689        Self {
690            namespace: namespace.into(),
691            providers,
692        }
693    }
694
695    pub fn providers(&self) -> &[Arc<dyn SecretProvider>] {
696        &self.providers
697    }
698}
699
700#[async_trait]
701impl SecretProvider for ChainSecretProvider {
702    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
703        if self.providers.is_empty() {
704            return Err(SecretError::NoProviders {
705                namespace: self.namespace.clone(),
706            });
707        }
708
709        let mut errors = Vec::new();
710        for provider in &self.providers {
711            match provider.get(id).await {
712                Ok(secret) => return Ok(secret),
713                Err(error) => errors.push(error),
714            }
715        }
716
717        Err(SecretError::All(errors))
718    }
719
720    async fn contains(&self, id: &SecretId) -> Result<bool, SecretError> {
721        if self.providers.is_empty() {
722            return Err(SecretError::NoProviders {
723                namespace: self.namespace.clone(),
724            });
725        }
726
727        // Mirrors `get`: the first provider that holds it answers, and a chain
728        // where every provider errored reports every error rather than a bare
729        // "absent" that would read as a definitive no.
730        let mut errors = Vec::new();
731        for provider in &self.providers {
732            match provider.contains(id).await {
733                Ok(true) => return Ok(true),
734                Ok(false) => {}
735                Err(error) => errors.push(error),
736            }
737        }
738
739        if errors.is_empty() {
740            Ok(false)
741        } else {
742            Err(SecretError::All(errors))
743        }
744    }
745
746    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
747        if self.providers.is_empty() {
748            return Err(SecretError::NoProviders {
749                namespace: self.namespace.clone(),
750            });
751        }
752
753        let mut last_value = Some(value);
754        let mut errors = Vec::new();
755        for (index, provider) in self.providers.iter().enumerate() {
756            let attempt_value = if index + 1 == self.providers.len() {
757                last_value
758                    .take()
759                    .expect("final secret write attempt missing value")
760            } else {
761                last_value
762                    .as_ref()
763                    .expect("intermediate secret write attempt missing value")
764                    .reborrow()
765            };
766            match provider.put(id, attempt_value).await {
767                Ok(()) => return Ok(()),
768                Err(error) => errors.push(error),
769            }
770        }
771
772        Err(SecretError::All(errors))
773    }
774
775    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
776        if self.providers.is_empty() {
777            return Err(SecretError::NoProviders {
778                namespace: self.namespace.clone(),
779            });
780        }
781
782        let mut errors = Vec::new();
783        for provider in &self.providers {
784            match provider.rotate(id).await {
785                Ok(handle) => return Ok(handle),
786                Err(error) => errors.push(error),
787            }
788        }
789
790        Err(SecretError::All(errors))
791    }
792
793    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
794        if self.providers.is_empty() {
795            return Err(SecretError::NoProviders {
796                namespace: self.namespace.clone(),
797            });
798        }
799
800        let mut errors = Vec::new();
801        let mut merged = BTreeMap::<SecretId, SecretMeta>::new();
802        for provider in &self.providers {
803            match provider.list(prefix).await {
804                Ok(items) => {
805                    for item in items {
806                        merged.entry(item.id.clone()).or_insert(item);
807                    }
808                }
809                Err(error) => errors.push(error),
810            }
811        }
812
813        if merged.is_empty() && !errors.is_empty() {
814            return Err(SecretError::All(errors));
815        }
816
817        Ok(merged.into_values().collect())
818    }
819
820    async fn delete_scoped(&self, request: SecretDeleteRequest) -> Result<(), SecretError> {
821        ensure_scoped_secret_access_allowed("delete", &request.id)?;
822        if self.providers.is_empty() {
823            return Err(SecretError::NoProviders {
824                namespace: self.namespace.clone(),
825            });
826        }
827
828        // Delete from every backend that supports it so a stale copy in one
829        // provider can't resurrect a credential the caller asked to revoke.
830        // A `NotFound` counts as success — the secret is already gone there.
831        let mut errors = Vec::new();
832        let mut any_ok = false;
833        for provider in &self.providers {
834            match provider
835                .delete_scoped(SecretDeleteRequest {
836                    id: request.id.clone(),
837                    scope: request.scope.clone(),
838                    audit: request.audit.clone(),
839                })
840                .await
841            {
842                Ok(()) => any_ok = true,
843                Err(error) if error.is_not_found() => any_ok = true,
844                Err(
845                    error @ SecretError::Unsupported {
846                        operation: "delete",
847                        ..
848                    },
849                ) => {
850                    // A read-only source is harmless only when it proves this
851                    // ID absent. Otherwise a later lookup could resurrect the
852                    // value after another provider successfully removed it.
853                    match provider.contains(&request.id).await {
854                        Ok(false) => any_ok = true,
855                        Ok(true) => errors.push(error),
856                        Err(error) => errors.push(error),
857                    }
858                }
859                Err(error) => errors.push(error),
860            }
861        }
862
863        if any_ok && errors.is_empty() {
864            Ok(())
865        } else {
866            Err(SecretError::All(errors))
867        }
868    }
869
870    fn namespace(&self) -> &str {
871        &self.namespace
872    }
873
874    fn supports_versions(&self) -> bool {
875        self.providers
876            .iter()
877            .any(|provider| provider.supports_versions())
878    }
879}
880
881/// The namespace this process stores and resolves credentials under.
882///
883/// `HARN_SECRET_NAMESPACE` overrides it for isolated workspaces and tests.
884/// Callers must not derive their own namespace: a namespace that only one
885/// surface computes is a credential only that surface can read.
886pub fn configured_secret_namespace() -> String {
887    match std::env::var(SECRET_NAMESPACE_ENV) {
888        Ok(namespace) if !namespace.trim().is_empty() => namespace,
889        _ => DEFAULT_SECRET_NAMESPACE.to_string(),
890    }
891}
892
893/// Build the process-wide secret chain over [`configured_secret_namespace`].
894pub fn configured_secret_chain() -> Result<ChainSecretProvider, SecretError> {
895    configured_default_chain(configured_secret_namespace())
896}
897
898pub fn configured_default_chain(
899    namespace: impl Into<String>,
900) -> Result<ChainSecretProvider, SecretError> {
901    let namespace = namespace.into();
902    let configured = std::env::var(SECRET_PROVIDER_CHAIN_ENV)
903        .unwrap_or_else(|_| DEFAULT_SECRET_PROVIDER_CHAIN.to_string());
904    let mut providers: Vec<Arc<dyn SecretProvider>> = Vec::new();
905
906    for raw_name in configured.split(',') {
907        let provider_name = raw_name.trim();
908        if provider_name.is_empty() {
909            continue;
910        }
911        match provider_name {
912            "env" => providers.push(Arc::new(EnvSecretProvider::new(namespace.clone()))),
913            "keyring" => providers.push(Arc::new(KeyringSecretProvider::new(namespace.clone()))),
914            "file" => {
915                let path = std::env::var_os(SECRET_FILE_PATH_ENV)
916                    .filter(|path| !path.is_empty())
917                    .ok_or_else(|| SecretError::InvalidConfig(format!(
918                        "the file secret provider requires {SECRET_FILE_PATH_ENV}"
919                    )))?;
920                providers.push(Arc::new(FileSecretProvider::new(path)?));
921            }
922            other => {
923                return Err(SecretError::InvalidConfig(format!(
924                    "unsupported secret provider '{other}' in {SECRET_PROVIDER_CHAIN_ENV}; expected a comma-separated list of env,keyring,file"
925                )))
926            }
927        }
928    }
929
930    Ok(ChainSecretProvider::new(namespace, providers))
931}
932
933pub(crate) fn emit_secret_access_event(provider: &str, id: &SecretId) {
934    #[derive(Serialize)]
935    struct SecretAccessEvent<'a> {
936        topic: &'a str,
937        provider: &'a str,
938        id: &'a SecretId,
939        caller_span_id: Option<u64>,
940        mutation_session_id: Option<String>,
941        timestamp: String,
942    }
943
944    let event = SecretAccessEvent {
945        topic: "audit.secret_access",
946        provider,
947        id,
948        caller_span_id: crate::tracing::current_span_id(),
949        mutation_session_id: crate::orchestration::current_mutation_session()
950            .map(|session| session.session_id),
951        timestamp: crate::orchestration::now_unix_seconds_text(),
952    };
953    let metadata = serde_json::to_value(event)
954        .ok()
955        .and_then(|value| value.as_object().cloned())
956        .map(|object| object.into_iter().collect::<BTreeMap<_, _>>())
957        .unwrap_or_default();
958    crate::events::log_info_meta("secret.audit", "secret accessed", metadata);
959}
960
961#[cfg(test)]
962mod tests {
963    use std::sync::{Arc, Mutex};
964
965    use async_trait::async_trait;
966
967    use super::*;
968
969    struct FakeProvider {
970        namespace: String,
971        result: Mutex<Vec<Result<SecretBytes, SecretError>>>,
972    }
973
974    impl FakeProvider {
975        fn new(
976            namespace: impl Into<String>,
977            result: Vec<Result<SecretBytes, SecretError>>,
978        ) -> Self {
979            Self {
980                namespace: namespace.into(),
981                result: Mutex::new(result),
982            }
983        }
984    }
985
986    #[async_trait]
987    impl SecretProvider for FakeProvider {
988        async fn get(&self, _id: &SecretId) -> Result<SecretBytes, SecretError> {
989            self.result
990                .lock()
991                .expect("fake provider poisoned")
992                .remove(0)
993        }
994
995        async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
996            Err(SecretError::Unsupported {
997                provider: self.namespace.clone(),
998                operation: "put",
999            })
1000        }
1001
1002        async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
1003            Err(SecretError::Unsupported {
1004                provider: self.namespace.clone(),
1005                operation: "rotate",
1006            })
1007        }
1008
1009        async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
1010            Err(SecretError::Unsupported {
1011                provider: self.namespace.clone(),
1012                operation: "list",
1013            })
1014        }
1015
1016        fn namespace(&self) -> &str {
1017            &self.namespace
1018        }
1019
1020        fn supports_versions(&self) -> bool {
1021            false
1022        }
1023    }
1024
1025    #[test]
1026    fn secret_bytes_debug_is_redacted() {
1027        let secret = SecretBytes::from("abcd");
1028        assert_eq!(format!("{secret:?}"), "SecretBytes { redacted: 4 bytes }");
1029    }
1030
1031    #[test]
1032    fn parse_secret_ref_accepts_namespace_name_and_version() {
1033        let id = parse_secret_ref("harn-secret://provider/anthropic-api-key@7")
1034            .expect("parse should succeed")
1035            .expect("secret ref should be detected");
1036        assert_eq!(id.namespace, "provider");
1037        assert_eq!(id.name, "anthropic-api-key");
1038        assert_eq!(id.version, SecretVersion::Exact(7));
1039    }
1040
1041    #[test]
1042    fn parse_secret_ref_ignores_non_refs_and_rejects_malformed_refs() {
1043        assert!(parse_secret_ref("plain-api-key")
1044            .expect("non-ref should be accepted")
1045            .is_none());
1046        assert!(parse_secret_ref("harn-secret://missing-name")
1047            .expect_err("missing slash should fail")
1048            .to_string()
1049            .contains("invalid secret reference"));
1050    }
1051
1052    #[test]
1053    fn parse_secret_id_accepts_canonical_and_ref_forms() {
1054        let canonical = parse_secret_id("google_workspace/access-token@2").expect("canonical id");
1055        assert_eq!(canonical.namespace, "google_workspace");
1056        assert_eq!(canonical.name, "access-token");
1057        assert_eq!(canonical.version, SecretVersion::Exact(2));
1058
1059        let reference =
1060            parse_secret_id("harn-secret://google_workspace/refresh-token").expect("ref id");
1061        assert_eq!(reference, connector_refresh_token_id("google_workspace"));
1062
1063        assert_eq!(
1064            connector_oauth_token_id("google_workspace").name,
1065            CONNECTOR_OAUTH_TOKEN_SECRET_NAME
1066        );
1067        assert_eq!(
1068            connector_access_token_id("google_workspace").name,
1069            CONNECTOR_ACCESS_TOKEN_SECRET_NAME
1070        );
1071    }
1072
1073    #[test]
1074    fn secret_bytes_zeroes_on_drop() {
1075        let probe = Arc::new(Mutex::new(None));
1076        let mut secret = SecretBytes::from("super-secret");
1077        secret.attach_drop_probe(probe.clone());
1078        drop(secret);
1079
1080        let dropped = probe
1081            .lock()
1082            .expect("drop probe poisoned")
1083            .clone()
1084            .expect("probe should capture bytes");
1085        assert!(dropped.iter().all(|byte| *byte == 0));
1086    }
1087
1088    #[tokio::test]
1089    async fn chain_secret_provider_falls_through_to_next_hit() {
1090        let id = SecretId::new("harn.test", "api-key");
1091        let first = Arc::new(FakeProvider::new(
1092            "first",
1093            vec![Err(SecretError::NotFound {
1094                provider: "first".to_string(),
1095                id: id.clone(),
1096            })],
1097        ));
1098        let second = Arc::new(FakeProvider::new(
1099            "second",
1100            vec![Ok(SecretBytes::from("value"))],
1101        ));
1102        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
1103
1104        let secret = chain.get(&id).await.expect("chain should resolve");
1105        let exposed = secret.with_exposed(|bytes| bytes.to_vec());
1106        assert_eq!(exposed, b"value");
1107    }
1108
1109    #[tokio::test]
1110    async fn chain_secret_provider_returns_all_errors_when_everything_fails() {
1111        let id = SecretId::new("harn.test", "missing");
1112        let first = Arc::new(FakeProvider::new(
1113            "first",
1114            vec![Err(SecretError::NotFound {
1115                provider: "first".to_string(),
1116                id: id.clone(),
1117            })],
1118        ));
1119        let second = Arc::new(FakeProvider::new(
1120            "second",
1121            vec![Err(SecretError::Backend {
1122                provider: "second".to_string(),
1123                message: "boom".to_string(),
1124            })],
1125        ));
1126        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
1127
1128        let error = chain.get(&id).await.expect_err("chain should fail");
1129        match error {
1130            SecretError::All(errors) => {
1131                assert_eq!(errors.len(), 2);
1132                assert!(matches!(errors[0], SecretError::NotFound { .. }));
1133                assert!(matches!(errors[1], SecretError::Backend { .. }));
1134            }
1135            other => panic!("expected aggregated errors, got {other:?}"),
1136        }
1137    }
1138
1139    #[tokio::test]
1140    async fn scoped_secret_access_denies_runtime_reserved_namespaces() {
1141        let chain = ChainSecretProvider::new(
1142            "harn/test",
1143            vec![Arc::new(FakeProvider::new("unused", Vec::new()))],
1144        );
1145
1146        for namespace in ["provenance", "harn.provenance", "harn.provenance.agent"] {
1147            let id = SecretId::new(namespace, "harn-cli.ed25519.seed");
1148            let error = chain
1149                .read_scoped(SecretReadRequest {
1150                    id: id.clone(),
1151                    scope: SecretScope::custom("provenance", None),
1152                    audit: SecretAuditContext::default(),
1153                })
1154                .await
1155                .expect_err("reserved namespace should be denied before backend access");
1156            match error {
1157                SecretError::AccessDenied {
1158                    operation,
1159                    id: denied_id,
1160                    message,
1161                } => {
1162                    assert_eq!(operation, "read");
1163                    assert_eq!(denied_id, id);
1164                    assert!(message.contains("reserved for Harn runtime provenance signing"));
1165                }
1166                other => panic!("expected access-denied error, got {other:?}"),
1167            }
1168        }
1169    }
1170
1171    #[tokio::test]
1172    async fn keyring_provider_round_trips_and_zeroes_on_drop() {
1173        let provider = KeyringSecretProvider::with_store(
1174            "harn.test",
1175            keyring_core::mock::Store::new().unwrap(),
1176        );
1177        let id = SecretId::new("", format!("mock-{}", uuid::Uuid::now_v7()));
1178        provider
1179            .put(&id, SecretBytes::from("round-trip-secret"))
1180            .await
1181            .expect("mock keyring write should succeed");
1182
1183        let probe = Arc::new(Mutex::new(None));
1184        let mut secret = provider
1185            .get(&id)
1186            .await
1187            .expect("mock keyring read should succeed");
1188        assert_eq!(
1189            secret.with_exposed(|bytes| bytes.to_vec()),
1190            b"round-trip-secret"
1191        );
1192        secret.attach_drop_probe(probe.clone());
1193        drop(secret);
1194
1195        let dropped = probe
1196            .lock()
1197            .expect("drop probe poisoned")
1198            .clone()
1199            .expect("probe should capture bytes");
1200        assert!(dropped.iter().all(|byte| *byte == 0));
1201
1202        provider
1203            .delete(&id)
1204            .await
1205            .expect("mock keyring delete should succeed");
1206    }
1207}