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