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