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 SecretWriteRequest {
268    pub id: SecretId,
269    pub scope: SecretScope,
270    pub value: SecretBytes,
271    pub options: SecretWriteOptions,
272    pub audit: SecretAuditContext,
273}
274
275#[derive(Debug)]
276pub struct SecretRotateRequest {
277    pub id: SecretId,
278    pub scope: SecretScope,
279    pub value: SecretBytes,
280    pub options: SecretRotationOptions,
281    pub audit: SecretAuditContext,
282}
283
284#[derive(Debug)]
285pub struct SecretLeaseRequest {
286    pub id: SecretId,
287    pub scope: SecretScope,
288    pub duration: Duration,
289    pub audit: SecretAuditContext,
290}
291
292#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
293pub struct SecretWriteReceipt {
294    pub provider: String,
295    pub id: SecretId,
296    pub scope: SecretScope,
297    pub version: Option<u64>,
298    pub expires_at_unix_ms: Option<i64>,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
302pub struct SecretRotationReceipt {
303    pub provider: String,
304    pub id: SecretId,
305    pub scope: SecretScope,
306    pub from_version: Option<u64>,
307    pub to_version: Option<u64>,
308    pub grace_until_unix_ms: Option<i64>,
309    pub expires_at_unix_ms: Option<i64>,
310}
311
312#[derive(Debug)]
313pub struct SecretLeaseGrant {
314    pub provider: String,
315    pub id: SecretId,
316    pub scope: SecretScope,
317    pub lease_id: String,
318    pub value: SecretBytes,
319    pub expires_at_unix_ms: i64,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq)]
323pub enum SecretError {
324    NotFound {
325        provider: String,
326        id: SecretId,
327    },
328    Unsupported {
329        provider: String,
330        operation: &'static str,
331    },
332    Backend {
333        provider: String,
334        message: String,
335    },
336    AccessDenied {
337        operation: String,
338        id: SecretId,
339        message: String,
340    },
341    InvalidConfig(String),
342    InvalidInput(String),
343    NoProviders {
344        namespace: String,
345    },
346    All(Vec<SecretError>),
347}
348
349impl fmt::Display for SecretError {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        match self {
352            Self::NotFound { provider, id } => {
353                write!(f, "{provider}: secret '{id}' not found")
354            }
355            Self::Unsupported {
356                provider,
357                operation,
358            } => write!(f, "{provider}: operation '{operation}' is unsupported"),
359            Self::Backend { provider, message } => write!(f, "{provider}: {message}"),
360            Self::AccessDenied {
361                operation,
362                id,
363                message,
364            } => write!(f, "secret {operation} denied for '{id}': {message}"),
365            Self::InvalidConfig(message) => write!(f, "{message}"),
366            Self::InvalidInput(message) => write!(f, "{message}"),
367            Self::NoProviders { namespace } => {
368                write!(
369                    f,
370                    "no secret providers configured for namespace '{namespace}'"
371                )
372            }
373            Self::All(errors) => {
374                let rendered = errors
375                    .iter()
376                    .map(ToString::to_string)
377                    .collect::<Vec<_>>()
378                    .join("; ");
379                write!(f, "all secret providers failed: {rendered}")
380            }
381        }
382    }
383}
384
385impl std::error::Error for SecretError {}
386
387#[derive(Default)]
388struct SecretBuffer {
389    bytes: Vec<u8>,
390    #[cfg(test)]
391    drop_probe: Option<std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>>,
392}
393
394impl SecretBuffer {
395    fn new(bytes: Vec<u8>) -> Self {
396        Self {
397            bytes,
398            #[cfg(test)]
399            drop_probe: None,
400        }
401    }
402
403    fn as_slice(&self) -> &[u8] {
404        &self.bytes
405    }
406
407    #[cfg(test)]
408    fn attach_drop_probe(&mut self, probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>) {
409        self.drop_probe = Some(probe);
410    }
411}
412
413impl std::ops::Deref for SecretBuffer {
414    type Target = [u8];
415
416    fn deref(&self) -> &Self::Target {
417        self.as_slice()
418    }
419}
420
421impl Zeroize for SecretBuffer {
422    fn zeroize(&mut self) {
423        self.bytes.zeroize();
424    }
425}
426
427impl Drop for SecretBuffer {
428    fn drop(&mut self) {
429        #[cfg(test)]
430        if let Some(probe) = &self.drop_probe {
431            *probe.lock().expect("drop probe poisoned") = Some(self.bytes.clone());
432        }
433    }
434}
435
436pub struct SecretBytes(Zeroizing<SecretBuffer>);
437
438impl SecretBytes {
439    pub fn new(bytes: Vec<u8>) -> Self {
440        Self(Zeroizing::new(SecretBuffer::new(bytes)))
441    }
442
443    pub fn len(&self) -> usize {
444        self.0.as_slice().len()
445    }
446
447    pub fn is_empty(&self) -> bool {
448        self.0.as_slice().is_empty()
449    }
450
451    pub fn with_exposed<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
452        f(self.0.as_slice())
453    }
454
455    pub fn reborrow(&self) -> Self {
456        self.with_exposed(|bytes| Self::new(bytes.to_vec()))
457    }
458
459    #[cfg(test)]
460    pub(crate) fn attach_drop_probe(
461        &mut self,
462        probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>,
463    ) {
464        self.0.attach_drop_probe(probe);
465    }
466}
467
468impl fmt::Debug for SecretBytes {
469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470        write!(f, "SecretBytes {{ redacted: {} bytes }}", self.len())
471    }
472}
473
474impl Serialize for SecretBytes {
475    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
476    where
477        S: serde::Serializer,
478    {
479        serializer.serialize_str(&format!("<redacted:{} bytes>", self.len()))
480    }
481}
482
483impl From<Vec<u8>> for SecretBytes {
484    fn from(value: Vec<u8>) -> Self {
485        Self::new(value)
486    }
487}
488
489impl From<String> for SecretBytes {
490    fn from(value: String) -> Self {
491        Self::new(value.into_bytes())
492    }
493}
494
495impl From<&str> for SecretBytes {
496    fn from(value: &str) -> Self {
497        Self::new(value.as_bytes().to_vec())
498    }
499}
500
501impl From<&[u8]> for SecretBytes {
502    fn from(value: &[u8]) -> Self {
503        Self::new(value.to_vec())
504    }
505}
506
507#[async_trait]
508pub trait SecretProvider: Send + Sync {
509    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError>;
510    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError>;
511    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError>;
512    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError>;
513
514    async fn read_scoped(&self, request: SecretReadRequest) -> Result<SecretBytes, SecretError> {
515        ensure_scoped_secret_access_allowed("read", &request.id)?;
516        self.get(&request.id).await
517    }
518
519    async fn write_scoped(
520        &self,
521        request: SecretWriteRequest,
522    ) -> Result<SecretWriteReceipt, SecretError> {
523        ensure_scoped_secret_access_allowed("write", &request.id)?;
524        if request.options.ttl.is_some() {
525            return Err(SecretError::Unsupported {
526                provider: self.namespace().to_string(),
527                operation: "write_ttl",
528            });
529        }
530        self.put(&request.id, request.value).await?;
531        Ok(SecretWriteReceipt {
532            provider: self.namespace().to_string(),
533            id: request.id,
534            scope: request.scope,
535            version: None,
536            expires_at_unix_ms: None,
537        })
538    }
539
540    async fn rotate_scoped(
541        &self,
542        request: SecretRotateRequest,
543    ) -> Result<SecretRotationReceipt, SecretError> {
544        ensure_scoped_secret_access_allowed("rotate", &request.id)?;
545        let _ = request;
546        Err(SecretError::Unsupported {
547            provider: self.namespace().to_string(),
548            operation: "rotate_to",
549        })
550    }
551
552    async fn lease_scoped(
553        &self,
554        request: SecretLeaseRequest,
555    ) -> Result<SecretLeaseGrant, SecretError> {
556        ensure_scoped_secret_access_allowed("lease", &request.id)?;
557        let _ = request;
558        Err(SecretError::Unsupported {
559            provider: self.namespace().to_string(),
560            operation: "lease",
561        })
562    }
563
564    fn namespace(&self) -> &str;
565    fn supports_versions(&self) -> bool;
566}
567
568pub fn ensure_scoped_secret_access_allowed(
569    operation: impl Into<String>,
570    id: &SecretId,
571) -> Result<(), SecretError> {
572    if is_runtime_reserved_secret_namespace(&id.namespace) {
573        return Err(SecretError::AccessDenied {
574            operation: operation.into(),
575            id: id.clone(),
576            message: format!(
577                "namespace `{}` is reserved for Harn runtime provenance signing and is not accessible through agent-scoped secret APIs",
578                id.namespace
579            ),
580        });
581    }
582    Ok(())
583}
584
585pub fn is_runtime_reserved_secret_namespace(namespace: &str) -> bool {
586    let namespace = namespace.trim_matches('.');
587    namespace == RUNTIME_PROVENANCE_SECRET_NAMESPACE
588        || namespace == SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE
589        || namespace
590            .strip_prefix(SCOPED_RUNTIME_PROVENANCE_SECRET_NAMESPACE)
591            .is_some_and(|suffix| suffix.starts_with('.'))
592}
593
594pub struct ChainSecretProvider {
595    namespace: String,
596    providers: Vec<Arc<dyn SecretProvider>>,
597}
598
599impl ChainSecretProvider {
600    pub fn new(namespace: impl Into<String>, providers: Vec<Arc<dyn SecretProvider>>) -> Self {
601        Self {
602            namespace: namespace.into(),
603            providers,
604        }
605    }
606
607    pub fn providers(&self) -> &[Arc<dyn SecretProvider>] {
608        &self.providers
609    }
610}
611
612#[async_trait]
613impl SecretProvider for ChainSecretProvider {
614    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
615        if self.providers.is_empty() {
616            return Err(SecretError::NoProviders {
617                namespace: self.namespace.clone(),
618            });
619        }
620
621        let mut errors = Vec::new();
622        for provider in &self.providers {
623            match provider.get(id).await {
624                Ok(secret) => return Ok(secret),
625                Err(error) => errors.push(error),
626            }
627        }
628
629        Err(SecretError::All(errors))
630    }
631
632    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
633        if self.providers.is_empty() {
634            return Err(SecretError::NoProviders {
635                namespace: self.namespace.clone(),
636            });
637        }
638
639        let mut last_value = Some(value);
640        let mut errors = Vec::new();
641        for (index, provider) in self.providers.iter().enumerate() {
642            let attempt_value = if index + 1 == self.providers.len() {
643                last_value
644                    .take()
645                    .expect("final secret write attempt missing value")
646            } else {
647                last_value
648                    .as_ref()
649                    .expect("intermediate secret write attempt missing value")
650                    .reborrow()
651            };
652            match provider.put(id, attempt_value).await {
653                Ok(()) => return Ok(()),
654                Err(error) => errors.push(error),
655            }
656        }
657
658        Err(SecretError::All(errors))
659    }
660
661    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
662        if self.providers.is_empty() {
663            return Err(SecretError::NoProviders {
664                namespace: self.namespace.clone(),
665            });
666        }
667
668        let mut errors = Vec::new();
669        for provider in &self.providers {
670            match provider.rotate(id).await {
671                Ok(handle) => return Ok(handle),
672                Err(error) => errors.push(error),
673            }
674        }
675
676        Err(SecretError::All(errors))
677    }
678
679    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
680        if self.providers.is_empty() {
681            return Err(SecretError::NoProviders {
682                namespace: self.namespace.clone(),
683            });
684        }
685
686        let mut errors = Vec::new();
687        let mut merged = BTreeMap::<SecretId, SecretMeta>::new();
688        for provider in &self.providers {
689            match provider.list(prefix).await {
690                Ok(items) => {
691                    for item in items {
692                        merged.entry(item.id.clone()).or_insert(item);
693                    }
694                }
695                Err(error) => errors.push(error),
696            }
697        }
698
699        if merged.is_empty() && !errors.is_empty() {
700            return Err(SecretError::All(errors));
701        }
702
703        Ok(merged.into_values().collect())
704    }
705
706    fn namespace(&self) -> &str {
707        &self.namespace
708    }
709
710    fn supports_versions(&self) -> bool {
711        self.providers
712            .iter()
713            .any(|provider| provider.supports_versions())
714    }
715}
716
717pub fn configured_default_chain(
718    namespace: impl Into<String>,
719) -> Result<ChainSecretProvider, SecretError> {
720    let namespace = namespace.into();
721    let configured = std::env::var(SECRET_PROVIDER_CHAIN_ENV)
722        .unwrap_or_else(|_| DEFAULT_SECRET_PROVIDER_CHAIN.to_string());
723    let mut providers: Vec<Arc<dyn SecretProvider>> = Vec::new();
724
725    for raw_name in configured.split(',') {
726        let provider_name = raw_name.trim();
727        if provider_name.is_empty() {
728            continue;
729        }
730        match provider_name {
731            "env" => providers.push(Arc::new(EnvSecretProvider::new(namespace.clone()))),
732            "keyring" => providers.push(Arc::new(KeyringSecretProvider::new(namespace.clone()))),
733            other => {
734                return Err(SecretError::InvalidConfig(format!(
735                    "unsupported secret provider '{other}' in {SECRET_PROVIDER_CHAIN_ENV}; expected a comma-separated list of env,keyring"
736                )))
737            }
738        }
739    }
740
741    Ok(ChainSecretProvider::new(namespace, providers))
742}
743
744pub(crate) fn emit_secret_access_event(provider: &str, id: &SecretId) {
745    #[derive(Serialize)]
746    struct SecretAccessEvent<'a> {
747        topic: &'a str,
748        provider: &'a str,
749        id: &'a SecretId,
750        caller_span_id: Option<u64>,
751        mutation_session_id: Option<String>,
752        timestamp: String,
753    }
754
755    let event = SecretAccessEvent {
756        topic: "audit.secret_access",
757        provider,
758        id,
759        caller_span_id: crate::tracing::current_span_id(),
760        mutation_session_id: crate::orchestration::current_mutation_session()
761            .map(|session| session.session_id),
762        timestamp: crate::orchestration::now_rfc3339(),
763    };
764    let metadata = serde_json::to_value(event)
765        .ok()
766        .and_then(|value| value.as_object().cloned())
767        .map(|object| object.into_iter().collect::<BTreeMap<_, _>>())
768        .unwrap_or_default();
769    crate::events::log_info_meta("secret.audit", "secret accessed", metadata);
770}
771
772#[cfg(test)]
773mod tests {
774    use std::sync::{Arc, Mutex, Once};
775
776    use async_trait::async_trait;
777
778    use super::*;
779
780    fn install_mock_keyring() {
781        static INIT: Once = Once::new();
782        INIT.call_once(|| {
783            ::keyring::set_default_credential_builder(::keyring::mock::default_credential_builder());
784        });
785    }
786
787    struct FakeProvider {
788        namespace: String,
789        result: Mutex<Vec<Result<SecretBytes, SecretError>>>,
790    }
791
792    impl FakeProvider {
793        fn new(
794            namespace: impl Into<String>,
795            result: Vec<Result<SecretBytes, SecretError>>,
796        ) -> Self {
797            Self {
798                namespace: namespace.into(),
799                result: Mutex::new(result),
800            }
801        }
802    }
803
804    #[async_trait]
805    impl SecretProvider for FakeProvider {
806        async fn get(&self, _id: &SecretId) -> Result<SecretBytes, SecretError> {
807            self.result
808                .lock()
809                .expect("fake provider poisoned")
810                .remove(0)
811        }
812
813        async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
814            Err(SecretError::Unsupported {
815                provider: self.namespace.clone(),
816                operation: "put",
817            })
818        }
819
820        async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
821            Err(SecretError::Unsupported {
822                provider: self.namespace.clone(),
823                operation: "rotate",
824            })
825        }
826
827        async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
828            Err(SecretError::Unsupported {
829                provider: self.namespace.clone(),
830                operation: "list",
831            })
832        }
833
834        fn namespace(&self) -> &str {
835            &self.namespace
836        }
837
838        fn supports_versions(&self) -> bool {
839            false
840        }
841    }
842
843    #[test]
844    fn secret_bytes_debug_is_redacted() {
845        let secret = SecretBytes::from("abcd");
846        assert_eq!(format!("{secret:?}"), "SecretBytes { redacted: 4 bytes }");
847    }
848
849    #[test]
850    fn parse_secret_ref_accepts_namespace_name_and_version() {
851        let id = parse_secret_ref("harn-secret://provider/anthropic-api-key@7")
852            .expect("parse should succeed")
853            .expect("secret ref should be detected");
854        assert_eq!(id.namespace, "provider");
855        assert_eq!(id.name, "anthropic-api-key");
856        assert_eq!(id.version, SecretVersion::Exact(7));
857    }
858
859    #[test]
860    fn parse_secret_ref_ignores_non_refs_and_rejects_malformed_refs() {
861        assert!(parse_secret_ref("plain-api-key")
862            .expect("non-ref should be accepted")
863            .is_none());
864        assert!(parse_secret_ref("harn-secret://missing-name")
865            .expect_err("missing slash should fail")
866            .to_string()
867            .contains("invalid secret reference"));
868    }
869
870    #[test]
871    fn parse_secret_id_accepts_canonical_and_ref_forms() {
872        let canonical = parse_secret_id("google_workspace/access-token@2").expect("canonical id");
873        assert_eq!(canonical.namespace, "google_workspace");
874        assert_eq!(canonical.name, "access-token");
875        assert_eq!(canonical.version, SecretVersion::Exact(2));
876
877        let reference =
878            parse_secret_id("harn-secret://google_workspace/refresh-token").expect("ref id");
879        assert_eq!(reference, connector_refresh_token_id("google_workspace"));
880
881        assert_eq!(
882            connector_oauth_token_id("google_workspace").name,
883            CONNECTOR_OAUTH_TOKEN_SECRET_NAME
884        );
885        assert_eq!(
886            connector_access_token_id("google_workspace").name,
887            CONNECTOR_ACCESS_TOKEN_SECRET_NAME
888        );
889    }
890
891    #[test]
892    fn secret_bytes_zeroes_on_drop() {
893        let probe = Arc::new(Mutex::new(None));
894        let mut secret = SecretBytes::from("super-secret");
895        secret.attach_drop_probe(probe.clone());
896        drop(secret);
897
898        let dropped = probe
899            .lock()
900            .expect("drop probe poisoned")
901            .clone()
902            .expect("probe should capture bytes");
903        assert!(dropped.iter().all(|byte| *byte == 0));
904    }
905
906    #[tokio::test]
907    async fn chain_secret_provider_falls_through_to_next_hit() {
908        let id = SecretId::new("harn.test", "api-key");
909        let first = Arc::new(FakeProvider::new(
910            "first",
911            vec![Err(SecretError::NotFound {
912                provider: "first".to_string(),
913                id: id.clone(),
914            })],
915        ));
916        let second = Arc::new(FakeProvider::new(
917            "second",
918            vec![Ok(SecretBytes::from("value"))],
919        ));
920        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
921
922        let secret = chain.get(&id).await.expect("chain should resolve");
923        let exposed = secret.with_exposed(|bytes| bytes.to_vec());
924        assert_eq!(exposed, b"value");
925    }
926
927    #[tokio::test]
928    async fn chain_secret_provider_returns_all_errors_when_everything_fails() {
929        let id = SecretId::new("harn.test", "missing");
930        let first = Arc::new(FakeProvider::new(
931            "first",
932            vec![Err(SecretError::NotFound {
933                provider: "first".to_string(),
934                id: id.clone(),
935            })],
936        ));
937        let second = Arc::new(FakeProvider::new(
938            "second",
939            vec![Err(SecretError::Backend {
940                provider: "second".to_string(),
941                message: "boom".to_string(),
942            })],
943        ));
944        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
945
946        let error = chain.get(&id).await.expect_err("chain should fail");
947        match error {
948            SecretError::All(errors) => {
949                assert_eq!(errors.len(), 2);
950                assert!(matches!(errors[0], SecretError::NotFound { .. }));
951                assert!(matches!(errors[1], SecretError::Backend { .. }));
952            }
953            other => panic!("expected aggregated errors, got {other:?}"),
954        }
955    }
956
957    #[tokio::test]
958    async fn scoped_secret_access_denies_runtime_reserved_namespaces() {
959        let chain = ChainSecretProvider::new(
960            "harn/test",
961            vec![Arc::new(FakeProvider::new("unused", Vec::new()))],
962        );
963
964        for namespace in ["provenance", "harn.provenance", "harn.provenance.agent"] {
965            let id = SecretId::new(namespace, "harn-cli.ed25519.seed");
966            let error = chain
967                .read_scoped(SecretReadRequest {
968                    id: id.clone(),
969                    scope: SecretScope::custom("provenance", None),
970                    audit: SecretAuditContext::default(),
971                })
972                .await
973                .expect_err("reserved namespace should be denied before backend access");
974            match error {
975                SecretError::AccessDenied {
976                    operation,
977                    id: denied_id,
978                    message,
979                } => {
980                    assert_eq!(operation, "read");
981                    assert_eq!(denied_id, id);
982                    assert!(message.contains("reserved for Harn runtime provenance signing"));
983                }
984                other => panic!("expected access-denied error, got {other:?}"),
985            }
986        }
987    }
988
989    #[tokio::test]
990    async fn keyring_provider_round_trips_and_zeroes_on_drop() {
991        install_mock_keyring();
992
993        let provider = KeyringSecretProvider::new("harn.test");
994        let id = SecretId::new("", format!("mock-{}", uuid::Uuid::now_v7()));
995        provider
996            .put(&id, SecretBytes::from("round-trip-secret"))
997            .await
998            .expect("mock keyring write should succeed");
999
1000        let probe = Arc::new(Mutex::new(None));
1001        let mut secret = provider
1002            .get(&id)
1003            .await
1004            .expect("mock keyring read should succeed");
1005        assert_eq!(
1006            secret.with_exposed(|bytes| bytes.to_vec()),
1007            b"round-trip-secret"
1008        );
1009        secret.attach_drop_probe(probe.clone());
1010        drop(secret);
1011
1012        let dropped = probe
1013            .lock()
1014            .expect("drop probe poisoned")
1015            .clone()
1016            .expect("probe should capture bytes");
1017        assert!(dropped.iter().all(|byte| *byte == 0));
1018
1019        provider
1020            .delete(&id)
1021            .await
1022            .expect("mock keyring delete should succeed");
1023    }
1024}