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