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