Skip to main content

harn_vm/secrets/
mod.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use zeroize::{Zeroize, Zeroizing};
9
10mod env;
11mod keyring;
12
13pub use env::EnvSecretProvider;
14pub use keyring::KeyringSecretProvider;
15
16pub const DEFAULT_SECRET_PROVIDER_CHAIN: &str = "env,keyring";
17pub const SECRET_PROVIDER_CHAIN_ENV: &str = "HARN_SECRET_PROVIDERS";
18const RUNTIME_PROVENANCE_SECRET_NAMESPACE: &str = "provenance";
19const SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE: &str = "harn.provenance";
20
21#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
22pub enum SecretVersion {
23    #[default]
24    Latest,
25    Exact(u64),
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
29pub struct SecretId {
30    pub namespace: String,
31    pub name: String,
32    #[serde(default)]
33    pub version: SecretVersion,
34}
35
36impl SecretId {
37    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
38        Self {
39            namespace: namespace.into(),
40            name: name.into(),
41            version: SecretVersion::Latest,
42        }
43    }
44
45    pub fn with_version(mut self, version: SecretVersion) -> Self {
46        self.version = version;
47        self
48    }
49}
50
51impl fmt::Display for SecretId {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        if self.namespace.is_empty() {
54            write!(f, "{}", self.name)?;
55        } else {
56            write!(f, "{}/{}", self.namespace, self.name)?;
57        }
58        match self.version {
59            SecretVersion::Latest => Ok(()),
60            SecretVersion::Exact(version) => write!(f, "@{version}"),
61        }
62    }
63}
64
65#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub struct SecretMeta {
67    pub id: SecretId,
68    pub provider: String,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
72pub struct RotationHandle {
73    pub provider: String,
74    pub id: SecretId,
75    pub from_version: Option<u64>,
76    pub to_version: Option<u64>,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum SecretScope {
82    Tenant { id: Option<String> },
83    Workspace { id: String },
84    System,
85    Custom { kind: String, id: Option<String> },
86}
87
88impl Default for SecretScope {
89    fn default() -> Self {
90        Self::Tenant { id: None }
91    }
92}
93
94impl SecretScope {
95    pub fn tenant(id: Option<String>) -> Self {
96        Self::Tenant { id }
97    }
98
99    pub fn workspace(id: impl Into<String>) -> Self {
100        Self::Workspace { id: id.into() }
101    }
102
103    pub fn system() -> Self {
104        Self::System
105    }
106
107    pub fn custom(kind: impl Into<String>, id: Option<String>) -> Self {
108        Self::Custom {
109            kind: kind.into(),
110            id,
111        }
112    }
113
114    pub fn namespace(&self) -> String {
115        match self {
116            Self::Tenant { id: Some(id) } if !id.is_empty() => format!("harn.tenant.{id}"),
117            Self::Tenant { .. } => "harn.tenant".to_string(),
118            Self::Workspace { id } => format!("harn.workspace.{id}"),
119            Self::System => "harn.system".to_string(),
120            Self::Custom { kind, id: Some(id) } if !id.is_empty() => {
121                format!("harn.{kind}.{id}")
122            }
123            Self::Custom { kind, .. } => format!("harn.{kind}"),
124        }
125    }
126
127    pub fn kind(&self) -> &str {
128        match self {
129            Self::Tenant { .. } => "tenant",
130            Self::Workspace { .. } => "workspace",
131            Self::System => "system",
132            Self::Custom { kind, .. } => kind.as_str(),
133        }
134    }
135
136    pub fn id(&self) -> Option<&str> {
137        match self {
138            Self::Tenant { id } | Self::Custom { id, .. } => id.as_deref(),
139            Self::Workspace { id } => Some(id.as_str()),
140            Self::System => None,
141        }
142    }
143}
144
145#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
146pub struct SecretWriteOptions {
147    pub ttl: Option<Duration>,
148}
149
150#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
151pub struct SecretRotationOptions {
152    pub grace: Option<Duration>,
153    pub ttl: Option<Duration>,
154}
155
156#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
157pub struct SecretAuditContext {
158    pub request_id: Option<String>,
159    pub actor_subject: Option<String>,
160    pub actor_kind: Option<String>,
161}
162
163#[derive(Debug)]
164pub struct SecretReadRequest {
165    pub id: SecretId,
166    pub scope: SecretScope,
167    pub audit: SecretAuditContext,
168}
169
170#[derive(Debug)]
171pub struct SecretWriteRequest {
172    pub id: SecretId,
173    pub scope: SecretScope,
174    pub value: SecretBytes,
175    pub options: SecretWriteOptions,
176    pub audit: SecretAuditContext,
177}
178
179#[derive(Debug)]
180pub struct SecretRotateRequest {
181    pub id: SecretId,
182    pub scope: SecretScope,
183    pub value: SecretBytes,
184    pub options: SecretRotationOptions,
185    pub audit: SecretAuditContext,
186}
187
188#[derive(Debug)]
189pub struct SecretLeaseRequest {
190    pub id: SecretId,
191    pub scope: SecretScope,
192    pub duration: Duration,
193    pub audit: SecretAuditContext,
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
197pub struct SecretWriteReceipt {
198    pub provider: String,
199    pub id: SecretId,
200    pub scope: SecretScope,
201    pub version: Option<u64>,
202    pub expires_at_unix_ms: Option<i64>,
203}
204
205#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
206pub struct SecretRotationReceipt {
207    pub provider: String,
208    pub id: SecretId,
209    pub scope: SecretScope,
210    pub from_version: Option<u64>,
211    pub to_version: Option<u64>,
212    pub grace_until_unix_ms: Option<i64>,
213    pub expires_at_unix_ms: Option<i64>,
214}
215
216#[derive(Debug)]
217pub struct SecretLeaseGrant {
218    pub provider: String,
219    pub id: SecretId,
220    pub scope: SecretScope,
221    pub lease_id: String,
222    pub value: SecretBytes,
223    pub expires_at_unix_ms: i64,
224}
225
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub enum SecretError {
228    NotFound {
229        provider: String,
230        id: SecretId,
231    },
232    Unsupported {
233        provider: String,
234        operation: &'static str,
235    },
236    Backend {
237        provider: String,
238        message: String,
239    },
240    AccessDenied {
241        operation: String,
242        id: SecretId,
243        message: String,
244    },
245    InvalidConfig(String),
246    InvalidInput(String),
247    NoProviders {
248        namespace: String,
249    },
250    All(Vec<SecretError>),
251}
252
253impl fmt::Display for SecretError {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        match self {
256            Self::NotFound { provider, id } => {
257                write!(f, "{provider}: secret '{id}' not found")
258            }
259            Self::Unsupported {
260                provider,
261                operation,
262            } => write!(f, "{provider}: operation '{operation}' is unsupported"),
263            Self::Backend { provider, message } => write!(f, "{provider}: {message}"),
264            Self::AccessDenied {
265                operation,
266                id,
267                message,
268            } => write!(f, "secret {operation} denied for '{id}': {message}"),
269            Self::InvalidConfig(message) => write!(f, "{message}"),
270            Self::InvalidInput(message) => write!(f, "{message}"),
271            Self::NoProviders { namespace } => {
272                write!(
273                    f,
274                    "no secret providers configured for namespace '{namespace}'"
275                )
276            }
277            Self::All(errors) => {
278                let rendered = errors
279                    .iter()
280                    .map(ToString::to_string)
281                    .collect::<Vec<_>>()
282                    .join("; ");
283                write!(f, "all secret providers failed: {rendered}")
284            }
285        }
286    }
287}
288
289impl std::error::Error for SecretError {}
290
291#[derive(Default)]
292struct SecretBuffer {
293    bytes: Vec<u8>,
294    #[cfg(test)]
295    drop_probe: Option<std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>>,
296}
297
298impl SecretBuffer {
299    fn new(bytes: Vec<u8>) -> Self {
300        Self {
301            bytes,
302            #[cfg(test)]
303            drop_probe: None,
304        }
305    }
306
307    fn as_slice(&self) -> &[u8] {
308        &self.bytes
309    }
310
311    #[cfg(test)]
312    fn attach_drop_probe(&mut self, probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>) {
313        self.drop_probe = Some(probe);
314    }
315}
316
317impl std::ops::Deref for SecretBuffer {
318    type Target = [u8];
319
320    fn deref(&self) -> &Self::Target {
321        self.as_slice()
322    }
323}
324
325impl Zeroize for SecretBuffer {
326    fn zeroize(&mut self) {
327        self.bytes.zeroize();
328    }
329}
330
331impl Drop for SecretBuffer {
332    fn drop(&mut self) {
333        #[cfg(test)]
334        if let Some(probe) = &self.drop_probe {
335            *probe.lock().expect("drop probe poisoned") = Some(self.bytes.clone());
336        }
337    }
338}
339
340pub struct SecretBytes(Zeroizing<SecretBuffer>);
341
342impl SecretBytes {
343    pub fn new(bytes: Vec<u8>) -> Self {
344        Self(Zeroizing::new(SecretBuffer::new(bytes)))
345    }
346
347    pub fn len(&self) -> usize {
348        self.0.as_slice().len()
349    }
350
351    pub fn is_empty(&self) -> bool {
352        self.0.as_slice().is_empty()
353    }
354
355    pub fn with_exposed<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
356        f(self.0.as_slice())
357    }
358
359    pub fn reborrow(&self) -> Self {
360        self.with_exposed(|bytes| Self::new(bytes.to_vec()))
361    }
362
363    #[cfg(test)]
364    pub(crate) fn attach_drop_probe(
365        &mut self,
366        probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>,
367    ) {
368        self.0.attach_drop_probe(probe);
369    }
370}
371
372impl fmt::Debug for SecretBytes {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        write!(f, "SecretBytes {{ redacted: {} bytes }}", self.len())
375    }
376}
377
378impl Serialize for SecretBytes {
379    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
380    where
381        S: serde::Serializer,
382    {
383        serializer.serialize_str(&format!("<redacted:{} bytes>", self.len()))
384    }
385}
386
387impl From<Vec<u8>> for SecretBytes {
388    fn from(value: Vec<u8>) -> Self {
389        Self::new(value)
390    }
391}
392
393impl From<String> for SecretBytes {
394    fn from(value: String) -> Self {
395        Self::new(value.into_bytes())
396    }
397}
398
399impl From<&str> for SecretBytes {
400    fn from(value: &str) -> Self {
401        Self::new(value.as_bytes().to_vec())
402    }
403}
404
405impl From<&[u8]> for SecretBytes {
406    fn from(value: &[u8]) -> Self {
407        Self::new(value.to_vec())
408    }
409}
410
411#[async_trait]
412pub trait SecretProvider: Send + Sync {
413    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError>;
414    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError>;
415    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError>;
416    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError>;
417
418    async fn read_scoped(&self, request: SecretReadRequest) -> Result<SecretBytes, SecretError> {
419        ensure_scoped_secret_access_allowed("read", &request.id)?;
420        self.get(&request.id).await
421    }
422
423    async fn write_scoped(
424        &self,
425        request: SecretWriteRequest,
426    ) -> Result<SecretWriteReceipt, SecretError> {
427        ensure_scoped_secret_access_allowed("write", &request.id)?;
428        if request.options.ttl.is_some() {
429            return Err(SecretError::Unsupported {
430                provider: self.namespace().to_string(),
431                operation: "write_ttl",
432            });
433        }
434        self.put(&request.id, request.value).await?;
435        Ok(SecretWriteReceipt {
436            provider: self.namespace().to_string(),
437            id: request.id,
438            scope: request.scope,
439            version: None,
440            expires_at_unix_ms: None,
441        })
442    }
443
444    async fn rotate_scoped(
445        &self,
446        request: SecretRotateRequest,
447    ) -> Result<SecretRotationReceipt, SecretError> {
448        ensure_scoped_secret_access_allowed("rotate", &request.id)?;
449        let _ = request;
450        Err(SecretError::Unsupported {
451            provider: self.namespace().to_string(),
452            operation: "rotate_to",
453        })
454    }
455
456    async fn lease_scoped(
457        &self,
458        request: SecretLeaseRequest,
459    ) -> Result<SecretLeaseGrant, SecretError> {
460        ensure_scoped_secret_access_allowed("lease", &request.id)?;
461        let _ = request;
462        Err(SecretError::Unsupported {
463            provider: self.namespace().to_string(),
464            operation: "lease",
465        })
466    }
467
468    fn namespace(&self) -> &str;
469    fn supports_versions(&self) -> bool;
470}
471
472pub fn ensure_scoped_secret_access_allowed(
473    operation: impl Into<String>,
474    id: &SecretId,
475) -> Result<(), SecretError> {
476    if is_runtime_reserved_secret_namespace(&id.namespace) {
477        return Err(SecretError::AccessDenied {
478            operation: operation.into(),
479            id: id.clone(),
480            message: format!(
481                "namespace `{}` is reserved for Harn runtime provenance signing and is not accessible through agent-scoped secret APIs",
482                id.namespace
483            ),
484        });
485    }
486    Ok(())
487}
488
489pub fn is_runtime_reserved_secret_namespace(namespace: &str) -> bool {
490    let namespace = namespace.trim_matches('.');
491    namespace == RUNTIME_PROVENANCE_SECRET_NAMESPACE
492        || namespace == SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE
493        || namespace
494            .strip_prefix(SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE)
495            .is_some_and(|suffix| suffix.starts_with('.'))
496}
497
498pub struct ChainSecretProvider {
499    namespace: String,
500    providers: Vec<Arc<dyn SecretProvider>>,
501}
502
503impl ChainSecretProvider {
504    pub fn new(namespace: impl Into<String>, providers: Vec<Arc<dyn SecretProvider>>) -> Self {
505        Self {
506            namespace: namespace.into(),
507            providers,
508        }
509    }
510
511    pub fn providers(&self) -> &[Arc<dyn SecretProvider>] {
512        &self.providers
513    }
514}
515
516#[async_trait]
517impl SecretProvider for ChainSecretProvider {
518    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
519        if self.providers.is_empty() {
520            return Err(SecretError::NoProviders {
521                namespace: self.namespace.clone(),
522            });
523        }
524
525        let mut errors = Vec::new();
526        for provider in &self.providers {
527            match provider.get(id).await {
528                Ok(secret) => return Ok(secret),
529                Err(error) => errors.push(error),
530            }
531        }
532
533        Err(SecretError::All(errors))
534    }
535
536    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
537        if self.providers.is_empty() {
538            return Err(SecretError::NoProviders {
539                namespace: self.namespace.clone(),
540            });
541        }
542
543        let mut last_value = Some(value);
544        let mut errors = Vec::new();
545        for (index, provider) in self.providers.iter().enumerate() {
546            let attempt_value = if index + 1 == self.providers.len() {
547                last_value
548                    .take()
549                    .expect("final secret write attempt missing value")
550            } else {
551                last_value
552                    .as_ref()
553                    .expect("intermediate secret write attempt missing value")
554                    .reborrow()
555            };
556            match provider.put(id, attempt_value).await {
557                Ok(()) => return Ok(()),
558                Err(error) => errors.push(error),
559            }
560        }
561
562        Err(SecretError::All(errors))
563    }
564
565    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
566        if self.providers.is_empty() {
567            return Err(SecretError::NoProviders {
568                namespace: self.namespace.clone(),
569            });
570        }
571
572        let mut errors = Vec::new();
573        for provider in &self.providers {
574            match provider.rotate(id).await {
575                Ok(handle) => return Ok(handle),
576                Err(error) => errors.push(error),
577            }
578        }
579
580        Err(SecretError::All(errors))
581    }
582
583    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
584        if self.providers.is_empty() {
585            return Err(SecretError::NoProviders {
586                namespace: self.namespace.clone(),
587            });
588        }
589
590        let mut errors = Vec::new();
591        let mut merged = BTreeMap::<SecretId, SecretMeta>::new();
592        for provider in &self.providers {
593            match provider.list(prefix).await {
594                Ok(items) => {
595                    for item in items {
596                        merged.entry(item.id.clone()).or_insert(item);
597                    }
598                }
599                Err(error) => errors.push(error),
600            }
601        }
602
603        if merged.is_empty() && !errors.is_empty() {
604            return Err(SecretError::All(errors));
605        }
606
607        Ok(merged.into_values().collect())
608    }
609
610    fn namespace(&self) -> &str {
611        &self.namespace
612    }
613
614    fn supports_versions(&self) -> bool {
615        self.providers
616            .iter()
617            .any(|provider| provider.supports_versions())
618    }
619}
620
621pub fn configured_default_chain(
622    namespace: impl Into<String>,
623) -> Result<ChainSecretProvider, SecretError> {
624    let namespace = namespace.into();
625    let configured = std::env::var(SECRET_PROVIDER_CHAIN_ENV)
626        .unwrap_or_else(|_| DEFAULT_SECRET_PROVIDER_CHAIN.to_string());
627    let mut providers: Vec<Arc<dyn SecretProvider>> = Vec::new();
628
629    for raw_name in configured.split(',') {
630        let provider_name = raw_name.trim();
631        if provider_name.is_empty() {
632            continue;
633        }
634        match provider_name {
635            "env" => providers.push(Arc::new(EnvSecretProvider::new(namespace.clone()))),
636            "keyring" => providers.push(Arc::new(KeyringSecretProvider::new(namespace.clone()))),
637            other => {
638                return Err(SecretError::InvalidConfig(format!(
639                    "unsupported secret provider '{other}' in {SECRET_PROVIDER_CHAIN_ENV}; expected a comma-separated list of env,keyring"
640                )))
641            }
642        }
643    }
644
645    Ok(ChainSecretProvider::new(namespace, providers))
646}
647
648pub(crate) fn emit_secret_access_event(provider: &str, id: &SecretId) {
649    #[derive(Serialize)]
650    struct SecretAccessEvent<'a> {
651        topic: &'a str,
652        provider: &'a str,
653        id: &'a SecretId,
654        caller_span_id: Option<u64>,
655        mutation_session_id: Option<String>,
656        timestamp: String,
657    }
658
659    let event = SecretAccessEvent {
660        topic: "audit.secret_access",
661        provider,
662        id,
663        caller_span_id: crate::tracing::current_span_id(),
664        mutation_session_id: crate::orchestration::current_mutation_session()
665            .map(|session| session.session_id),
666        timestamp: crate::orchestration::now_rfc3339(),
667    };
668    let metadata = serde_json::to_value(event)
669        .ok()
670        .and_then(|value| value.as_object().cloned())
671        .map(|object| object.into_iter().collect::<BTreeMap<_, _>>())
672        .unwrap_or_default();
673    crate::events::log_info_meta("secret.audit", "secret accessed", metadata);
674}
675
676#[cfg(test)]
677mod tests {
678    use std::sync::{Arc, Mutex, Once};
679
680    use async_trait::async_trait;
681
682    use super::*;
683
684    fn install_mock_keyring() {
685        static INIT: Once = Once::new();
686        INIT.call_once(|| {
687            ::keyring::set_default_credential_builder(::keyring::mock::default_credential_builder());
688        });
689    }
690
691    struct FakeProvider {
692        namespace: String,
693        result: Mutex<Vec<Result<SecretBytes, SecretError>>>,
694    }
695
696    impl FakeProvider {
697        fn new(
698            namespace: impl Into<String>,
699            result: Vec<Result<SecretBytes, SecretError>>,
700        ) -> Self {
701            Self {
702                namespace: namespace.into(),
703                result: Mutex::new(result),
704            }
705        }
706    }
707
708    #[async_trait]
709    impl SecretProvider for FakeProvider {
710        async fn get(&self, _id: &SecretId) -> Result<SecretBytes, SecretError> {
711            self.result
712                .lock()
713                .expect("fake provider poisoned")
714                .remove(0)
715        }
716
717        async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
718            Err(SecretError::Unsupported {
719                provider: self.namespace.clone(),
720                operation: "put",
721            })
722        }
723
724        async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
725            Err(SecretError::Unsupported {
726                provider: self.namespace.clone(),
727                operation: "rotate",
728            })
729        }
730
731        async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
732            Err(SecretError::Unsupported {
733                provider: self.namespace.clone(),
734                operation: "list",
735            })
736        }
737
738        fn namespace(&self) -> &str {
739            &self.namespace
740        }
741
742        fn supports_versions(&self) -> bool {
743            false
744        }
745    }
746
747    #[test]
748    fn secret_bytes_debug_is_redacted() {
749        let secret = SecretBytes::from("abcd");
750        assert_eq!(format!("{secret:?}"), "SecretBytes { redacted: 4 bytes }");
751    }
752
753    #[test]
754    fn secret_bytes_zeroes_on_drop() {
755        let probe = Arc::new(Mutex::new(None));
756        let mut secret = SecretBytes::from("super-secret");
757        secret.attach_drop_probe(probe.clone());
758        drop(secret);
759
760        let dropped = probe
761            .lock()
762            .expect("drop probe poisoned")
763            .clone()
764            .expect("probe should capture bytes");
765        assert!(dropped.iter().all(|byte| *byte == 0));
766    }
767
768    #[tokio::test]
769    async fn chain_secret_provider_falls_through_to_next_hit() {
770        let id = SecretId::new("harn.test", "api-key");
771        let first = Arc::new(FakeProvider::new(
772            "first",
773            vec![Err(SecretError::NotFound {
774                provider: "first".to_string(),
775                id: id.clone(),
776            })],
777        ));
778        let second = Arc::new(FakeProvider::new(
779            "second",
780            vec![Ok(SecretBytes::from("value"))],
781        ));
782        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
783
784        let secret = chain.get(&id).await.expect("chain should resolve");
785        let exposed = secret.with_exposed(|bytes| bytes.to_vec());
786        assert_eq!(exposed, b"value");
787    }
788
789    #[tokio::test]
790    async fn chain_secret_provider_returns_all_errors_when_everything_fails() {
791        let id = SecretId::new("harn.test", "missing");
792        let first = Arc::new(FakeProvider::new(
793            "first",
794            vec![Err(SecretError::NotFound {
795                provider: "first".to_string(),
796                id: id.clone(),
797            })],
798        ));
799        let second = Arc::new(FakeProvider::new(
800            "second",
801            vec![Err(SecretError::Backend {
802                provider: "second".to_string(),
803                message: "boom".to_string(),
804            })],
805        ));
806        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
807
808        let error = chain.get(&id).await.expect_err("chain should fail");
809        match error {
810            SecretError::All(errors) => {
811                assert_eq!(errors.len(), 2);
812                assert!(matches!(errors[0], SecretError::NotFound { .. }));
813                assert!(matches!(errors[1], SecretError::Backend { .. }));
814            }
815            other => panic!("expected aggregated errors, got {other:?}"),
816        }
817    }
818
819    #[tokio::test]
820    async fn scoped_secret_access_denies_runtime_reserved_namespaces() {
821        let chain = ChainSecretProvider::new(
822            "harn/test",
823            vec![Arc::new(FakeProvider::new("unused", Vec::new()))],
824        );
825
826        for namespace in ["provenance", "harn.provenance", "harn.provenance.agent"] {
827            let id = SecretId::new(namespace, "harn-cli.ed25519.seed");
828            let error = chain
829                .read_scoped(SecretReadRequest {
830                    id: id.clone(),
831                    scope: SecretScope::custom("provenance", None),
832                    audit: SecretAuditContext::default(),
833                })
834                .await
835                .expect_err("reserved namespace should be denied before backend access");
836            match error {
837                SecretError::AccessDenied {
838                    operation,
839                    id: denied_id,
840                    message,
841                } => {
842                    assert_eq!(operation, "read");
843                    assert_eq!(denied_id, id);
844                    assert!(message.contains("reserved for Harn runtime provenance signing"));
845                }
846                other => panic!("expected access-denied error, got {other:?}"),
847            }
848        }
849    }
850
851    #[tokio::test]
852    async fn keyring_provider_round_trips_and_zeroes_on_drop() {
853        install_mock_keyring();
854
855        let provider = KeyringSecretProvider::new("harn.test");
856        let id = SecretId::new("", format!("mock-{}", uuid::Uuid::now_v7()));
857        provider
858            .put(&id, SecretBytes::from("round-trip-secret"))
859            .await
860            .expect("mock keyring write should succeed");
861
862        let probe = Arc::new(Mutex::new(None));
863        let mut secret = provider
864            .get(&id)
865            .await
866            .expect("mock keyring read should succeed");
867        assert_eq!(
868            secret.with_exposed(|bytes| bytes.to_vec()),
869            b"round-trip-secret"
870        );
871        secret.attach_drop_probe(probe.clone());
872        drop(secret);
873
874        let dropped = probe
875            .lock()
876            .expect("drop probe poisoned")
877            .clone()
878            .expect("probe should capture bytes");
879        assert!(dropped.iter().all(|byte| *byte == 0));
880
881        provider
882            .delete(&id)
883            .await
884            .expect("mock keyring delete should succeed");
885    }
886}