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}