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