1use std::collections::BTreeMap;
5use std::fmt;
6
7use runx_contracts::{
8 CredentialDeliveryMode, CredentialDeliveryObservation, CredentialDeliveryObservationStatus,
9 CredentialDeliveryPurpose, CredentialEnvelopeKind, ProofKind, Reference, ReferenceType,
10 sha256_hex, sha256_prefixed,
11};
12use runx_core::policy::{CredentialBindingDecision, CredentialEnvelope};
13use serde::Deserialize;
14use thiserror::Error;
15
16const REDACTED_CREDENTIAL: &str = "[redacted-credential]";
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct CredentialDeliveryProfile {
20 provider: String,
21 auth_mode: String,
22 env_bindings: Vec<CredentialEnvBinding>,
23}
24
25impl CredentialDeliveryProfile {
26 pub fn env_token(
27 provider: impl Into<String>,
28 auth_mode: impl Into<String>,
29 env_var: impl Into<String>,
30 ) -> Result<Self, CredentialDeliveryError> {
31 let env_var = env_var.into();
32 validate_env_name(&env_var)?;
33 Ok(Self {
34 provider: provider.into(),
35 auth_mode: auth_mode.into(),
36 env_bindings: vec![CredentialEnvBinding {
37 role: CredentialMaterialRole::ApiKey,
38 env_var,
39 required: true,
40 }],
41 })
42 }
43
44 #[must_use]
45 pub fn provider(&self) -> &str {
46 &self.provider
47 }
48
49 #[must_use]
50 pub fn auth_mode(&self) -> &str {
51 &self.auth_mode
52 }
53
54 pub fn from_contract_profile(
55 profile: &runx_contracts::CredentialDeliveryProfile,
56 ) -> Result<Self, CredentialDeliveryError> {
57 if profile.delivery_mode != runx_contracts::CredentialDeliveryMode::ProcessEnv {
58 return Err(CredentialDeliveryError::UnsupportedDeliveryMode {
59 mode: format!("{:?}", profile.delivery_mode),
60 });
61 }
62 let mut env_bindings = Vec::with_capacity(profile.env_bindings.len());
63 for binding in &profile.env_bindings {
64 let role = CredentialMaterialRole::from_contract_role(binding.role.clone());
65 validate_env_name(&binding.env_var)?;
66 env_bindings.push(CredentialEnvBinding {
67 role,
68 env_var: binding.env_var.clone(),
69 required: binding.required,
70 });
71 }
72 Ok(Self {
73 provider: profile.provider.to_string(),
74 auth_mode: profile.auth_mode.to_string(),
75 env_bindings,
76 })
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
81struct CredentialEnvBinding {
82 role: CredentialMaterialRole,
83 env_var: String,
84 required: bool,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
88pub enum CredentialMaterialRole {
89 PersonalToken,
90 ApiKey,
91 ClientSecret,
92 SessionToken,
93}
94
95impl CredentialMaterialRole {
96 const fn label(self) -> &'static str {
97 match self {
98 Self::PersonalToken => "personal_token",
99 Self::ApiKey => "api_key",
100 Self::ClientSecret => "client_secret",
101 Self::SessionToken => "session_token",
102 }
103 }
104
105 fn from_contract_role(role: runx_contracts::CredentialMaterialRole) -> Self {
106 match role {
107 runx_contracts::CredentialMaterialRole::PersonalToken => Self::PersonalToken,
108 runx_contracts::CredentialMaterialRole::ApiKey => Self::ApiKey,
109 runx_contracts::CredentialMaterialRole::ClientSecret => Self::ClientSecret,
110 runx_contracts::CredentialMaterialRole::SessionToken => Self::SessionToken,
111 }
112 }
113}
114
115pub trait MaterialResolver {
116 fn resolve_material(
117 &self,
118 material_ref: &str,
119 ) -> Result<ResolvedCredentialMaterial, CredentialDeliveryError>;
120}
121
122pub struct CredentialResolutionRequest<'a> {
123 pub decision: &'a CredentialBindingDecision,
124 pub credential: &'a CredentialEnvelope,
125 pub profile: &'a CredentialDeliveryProfile,
126 pub observation: CredentialDeliveryObservation,
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct CredentialResolution {
133 delivery: CredentialDelivery,
134}
135
136impl CredentialResolution {
137 #[must_use]
138 pub fn into_delivery(self) -> CredentialDelivery {
139 self.delivery
140 }
141}
142
143pub trait CredentialSupervisor {
144 fn resolve(
145 &self,
146 request: CredentialResolutionRequest<'_>,
147 ) -> Result<CredentialResolution, CredentialDeliveryError>;
148}
149
150pub struct MaterialCredentialSupervisor<'a, R> {
151 resolver: &'a R,
152}
153
154impl<'a, R> MaterialCredentialSupervisor<'a, R>
155where
156 R: MaterialResolver,
157{
158 #[must_use]
159 pub const fn new(resolver: &'a R) -> Self {
160 Self { resolver }
161 }
162}
163
164impl<R> CredentialSupervisor for MaterialCredentialSupervisor<'_, R>
165where
166 R: MaterialResolver,
167{
168 fn resolve(
169 &self,
170 request: CredentialResolutionRequest<'_>,
171 ) -> Result<CredentialResolution, CredentialDeliveryError> {
172 require_allowed_binding(request.decision)?;
173 if request.credential.provider != request.profile.provider {
174 return Err(CredentialDeliveryError::ProviderMismatch {
175 credential_provider: request.credential.provider.to_string(),
176 profile_provider: request.profile.provider.clone(),
177 });
178 }
179 let material = self
180 .resolver
181 .resolve_material(&request.credential.material_ref)?;
182 if material.material_ref != request.credential.material_ref {
183 return Err(CredentialDeliveryError::MaterialRefMismatch {
184 expected_hash: hash_material_ref(&request.credential.material_ref),
185 actual_hash: hash_material_ref(&material.material_ref),
186 });
187 }
188 Ok(CredentialResolution {
189 delivery: CredentialDelivery {
190 secret_env: apply_profile(request.profile, &material)?,
191 public_observation: Some(request.observation),
192 },
193 })
194 }
195}
196
197#[derive(Clone, Debug, Default)]
198pub struct InMemoryMaterialResolver {
199 materials: BTreeMap<String, ResolvedCredentialMaterial>,
200}
201
202impl InMemoryMaterialResolver {
203 #[must_use]
204 pub fn with_material(
205 material_ref: impl Into<String>,
206 material: ResolvedCredentialMaterial,
207 ) -> Self {
208 let mut materials = BTreeMap::new();
209 materials.insert(material_ref.into(), material);
210 Self { materials }
211 }
212}
213
214impl MaterialResolver for InMemoryMaterialResolver {
215 fn resolve_material(
216 &self,
217 material_ref: &str,
218 ) -> Result<ResolvedCredentialMaterial, CredentialDeliveryError> {
219 self.materials.get(material_ref).cloned().ok_or_else(|| {
220 CredentialDeliveryError::MaterialNotFound {
221 material_ref_hash: hash_material_ref(material_ref),
222 }
223 })
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct ResolvedCredentialMaterial {
229 material_ref: String,
230 values: BTreeMap<CredentialMaterialRole, SecretString>,
231}
232
233impl ResolvedCredentialMaterial {
234 #[must_use]
235 pub fn api_key(material_ref: impl Into<String>, value: impl Into<String>) -> Self {
236 Self::with_role(material_ref, CredentialMaterialRole::ApiKey, value)
237 }
238
239 #[must_use]
240 pub fn with_role(
241 material_ref: impl Into<String>,
242 role: CredentialMaterialRole,
243 value: impl Into<String>,
244 ) -> Self {
245 let mut values = BTreeMap::new();
246 values.insert(role, SecretString::new(value));
247 Self {
248 material_ref: material_ref.into(),
249 values,
250 }
251 }
252}
253
254#[derive(Clone, PartialEq, Eq)]
255pub struct SecretString(String);
256
257impl SecretString {
258 pub fn new(value: impl Into<String>) -> Self {
259 Self(value.into())
260 }
261
262 pub(crate) fn expose(&self) -> &str {
263 &self.0
264 }
265}
266
267impl fmt::Debug for SecretString {
268 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269 formatter.write_str(REDACTED_CREDENTIAL)
270 }
271}
272
273#[derive(Clone, Debug, Default, PartialEq, Eq)]
274pub struct SecretEnv {
275 values: BTreeMap<String, SecretString>,
276}
277
278impl SecretEnv {
279 #[must_use]
280 pub fn is_empty(&self) -> bool {
281 self.values.is_empty()
282 }
283
284 pub fn get(&self, key: &str) -> Option<&str> {
285 self.values.get(key).map(SecretString::expose)
286 }
287
288 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
289 self.values
290 .iter()
291 .map(|(key, value)| (key.as_str(), value.expose()))
292 }
293}
294
295#[derive(Clone, Debug, Default, PartialEq, Eq)]
296pub struct CredentialDelivery {
297 secret_env: SecretEnv,
298 public_observation: Option<runx_contracts::CredentialDeliveryObservation>,
299}
300
301impl CredentialDelivery {
302 #[must_use]
303 pub const fn none() -> Self {
304 Self {
305 secret_env: SecretEnv {
306 values: BTreeMap::new(),
307 },
308 public_observation: None,
309 }
310 }
311
312 pub fn from_local_descriptor(
321 provider: impl Into<String>,
322 auth_mode: impl Into<String>,
323 env_var: impl Into<String>,
324 material_ref: impl Into<String>,
325 scopes: Vec<String>,
326 secret: impl Into<String>,
327 ) -> Result<Self, CredentialDeliveryError> {
328 let provider = provider.into();
329 let auth_mode = auth_mode.into();
330 let material_ref = material_ref.into();
331
332 let observation = build_local_provision_observation(&provider, &auth_mode, &material_ref);
335
336 let profile =
337 CredentialDeliveryProfile::env_token(provider.clone(), auth_mode.clone(), env_var)?;
338 let envelope = CredentialEnvelope {
339 kind: CredentialEnvelopeKind::V1,
340 grant_id: material_ref.clone().into(),
341 provider: provider.into(),
342 auth_mode: auth_mode.into(),
343 material_kind: "api_key".into(),
344 provider_reference: "local_per_run".into(),
345 scopes: scopes.into_iter().map(Into::into).collect(),
346 grant_reference: None,
347 material_ref: material_ref.clone().into(),
348 };
349 let decision = CredentialBindingDecision::Allow {
350 reasons: vec!["local per-run credential provision".to_owned()],
351 };
352 let resolver = InMemoryMaterialResolver::with_material(
353 material_ref.clone(),
354 ResolvedCredentialMaterial::api_key(material_ref, secret),
355 );
356
357 Self::from_allowed_binding(&decision, &envelope, &profile, &resolver, observation)
358 }
359
360 pub fn from_hosted_handles_json(raw: &str) -> Result<Self, CredentialDeliveryError> {
361 let handles: Vec<HostedCredentialHandle> = serde_json::from_str(raw).map_err(|error| {
362 CredentialDeliveryError::HostedCredentialHandlesInvalid {
363 reason: error.to_string(),
364 }
365 })?;
366 Self::from_hosted_handles(&handles)
367 }
368
369 fn from_hosted_handles(
372 handles: &[HostedCredentialHandle],
373 ) -> Result<Self, CredentialDeliveryError> {
374 let Some(first) = handles.first() else {
375 return Ok(Self::none());
376 };
377 let provider = first.provider.trim();
378 if provider.is_empty() {
379 return Err(CredentialDeliveryError::HostedCredentialHandlesInvalid {
380 reason: "provider is required".to_owned(),
381 });
382 }
383 for handle in handles {
384 if handle.credential_ref.reference_type != ReferenceType::Credential {
385 return Err(CredentialDeliveryError::HostedCredentialRefType {
386 reference_type: handle.credential_ref.reference_type.as_str().to_owned(),
387 });
388 }
389 if handle.provider.trim() != provider || handle.purpose != first.purpose {
390 return Err(CredentialDeliveryError::HostedCredentialHandlesMixed);
391 }
392 }
393
394 let canonical = serde_json::to_vec(handles).map_err(|error| {
395 CredentialDeliveryError::HostedCredentialHandlesInvalid {
396 reason: error.to_string(),
397 }
398 })?;
399 let handles_id = sha256_hex(&canonical);
400 let mut refs = Vec::with_capacity(handles.len());
401 for handle in handles {
402 let mut credential_ref = handle.credential_ref.clone();
403 credential_ref.provider = Some(handle.provider.clone().into());
404 credential_ref.proof_kind = Some(ProofKind::CredentialResolution);
405 refs.push(credential_ref);
406 }
407
408 Ok(Self {
409 secret_env: SecretEnv::default(),
410 public_observation: Some(CredentialDeliveryObservation {
411 schema: runx_contracts::CredentialDeliveryObservationSchema::V1,
412 observation_id: format!("hosted-credential-delivery/{handles_id}").into(),
413 request_id: format!("hosted-credential-handles/{handles_id}").into(),
414 response_id: None,
415 status: CredentialDeliveryObservationStatus::Delivered,
416 harness_ref: Reference::with_uri(
417 ReferenceType::Harness,
418 "runx:harness:hosted-credential-handles",
419 ),
420 host_ref: Some(Reference::with_uri(
421 ReferenceType::Host,
422 "runx:host:hosted-runtime-service",
423 )),
424 profile_id: format!("{provider}-hosted-handles").into(),
425 provider: provider.to_owned().into(),
426 purpose: first.purpose.clone(),
427 delivery_mode: None,
428 credential_refs: refs,
429 material_ref_hash: None,
430 delivered_roles: Vec::new(),
431 redaction_refs: None,
432 observed_at: crate::time::now_iso8601().into(),
433 }),
434 })
435 }
436
437 pub fn from_allowed_binding<R: MaterialResolver>(
438 decision: &CredentialBindingDecision,
439 credential: &CredentialEnvelope,
440 profile: &CredentialDeliveryProfile,
441 resolver: &R,
442 observation: CredentialDeliveryObservation,
443 ) -> Result<Self, CredentialDeliveryError> {
444 MaterialCredentialSupervisor::new(resolver)
445 .resolve(CredentialResolutionRequest {
446 decision,
447 credential,
448 profile,
449 observation,
450 })
451 .map(CredentialResolution::into_delivery)
452 }
453
454 #[must_use]
455 pub fn secret_env(&self) -> &SecretEnv {
456 &self.secret_env
457 }
458
459 pub fn reject_process_env_boundary(
460 &self,
461 boundary: &'static str,
462 ) -> Result<(), CredentialDeliveryError> {
463 if self.secret_env.is_empty() {
464 return Ok(());
465 }
466 Err(CredentialDeliveryError::ProcessEnvBoundaryUnsupported {
467 boundary: boundary.to_owned(),
468 })
469 }
470
471 #[must_use]
472 pub fn with_public_observation(
473 mut self,
474 observation: runx_contracts::CredentialDeliveryObservation,
475 ) -> Self {
476 self.public_observation = Some(observation);
477 self
478 }
479
480 #[must_use]
481 pub fn public_observation(&self) -> Option<&runx_contracts::CredentialDeliveryObservation> {
482 self.public_observation.as_ref()
483 }
484
485 #[must_use]
486 pub fn credential_refs(&self) -> Option<Vec<runx_contracts::Reference>> {
487 self.public_observation.as_ref().and_then(|observation| {
488 (!observation.credential_refs.is_empty()).then(|| observation.credential_refs.clone())
489 })
490 }
491
492 #[must_use]
493 pub fn redact_text(&self, text: impl Into<String>) -> String {
494 let mut redacted = text.into();
495 for value in self.secret_env.values.values() {
496 let secret = value.expose();
497 if !secret.is_empty() {
498 redacted = redacted.replace(secret, REDACTED_CREDENTIAL);
499 }
500 }
501 redacted
502 }
503
504 #[must_use]
505 pub fn redact_bytes_to_string(&self, bytes: Vec<u8>, limit_bytes: usize) -> String {
506 let mut text = String::from_utf8_lossy(&bytes).into_owned();
507 text = self.redact_text(text);
508 truncate_utf8_string(&text, limit_bytes)
509 }
510}
511
512#[derive(Debug, Error)]
513pub enum CredentialDeliveryError {
514 #[error("credential binding denied: {}", reasons.join("; "))]
515 BindingDenied { reasons: Vec<String> },
516 #[error(
517 "credential provider '{credential_provider}' does not match delivery profile provider '{profile_provider}'"
518 )]
519 ProviderMismatch {
520 credential_provider: String,
521 profile_provider: String,
522 },
523 #[error("credential material with hash '{material_ref_hash}' was not found")]
524 MaterialNotFound { material_ref_hash: String },
525 #[error(
526 "credential material ref hash mismatch: expected '{expected_hash}', got '{actual_hash}'"
527 )]
528 MaterialRefMismatch {
529 expected_hash: String,
530 actual_hash: String,
531 },
532 #[error("credential material is missing role '{role}'")]
533 MissingRole { role: String },
534 #[error("credential material for role '{role}' is empty")]
535 EmptyMaterial { role: String },
536 #[error("invalid credential delivery env var '{name}'")]
537 InvalidEnvName { name: String },
538 #[error("unsupported credential delivery mode '{mode}'")]
539 UnsupportedDeliveryMode { mode: String },
540 #[error("credential process-env delivery is not supported across the '{boundary}' boundary")]
541 ProcessEnvBoundaryUnsupported { boundary: String },
542 #[error("invalid hosted credential handles: {reason}")]
543 HostedCredentialHandlesInvalid { reason: String },
544 #[error("hosted credential handles must share one provider and purpose")]
545 HostedCredentialHandlesMixed,
546 #[error("hosted credential handle reference must be type credential, got '{reference_type}'")]
547 HostedCredentialRefType { reference_type: String },
548}
549
550#[derive(Clone, Debug, Deserialize, serde::Serialize)]
551#[serde(deny_unknown_fields)]
552struct HostedCredentialHandle {
553 credential_ref: Reference,
554 provider: String,
555 purpose: CredentialDeliveryPurpose,
556}
557
558fn build_local_provision_observation(
564 provider: &str,
565 auth_mode: &str,
566 material_ref: &str,
567) -> CredentialDeliveryObservation {
568 let material_ref_hash = hash_material_ref(material_ref);
569 let material_ref_id = sha256_hex(material_ref.as_bytes());
570 CredentialDeliveryObservation {
571 schema: runx_contracts::CredentialDeliveryObservationSchema::V1,
572 observation_id: format!("local-credential-delivery/{material_ref_id}").into(),
573 request_id: format!("local-credential-provision/{material_ref_id}").into(),
574 response_id: None,
575 status: CredentialDeliveryObservationStatus::Delivered,
576 harness_ref: Reference::with_uri(
577 ReferenceType::Harness,
578 "runx:harness:local-credential-provision",
579 ),
580 host_ref: Some(Reference::with_uri(
581 ReferenceType::Host,
582 "runx:host:local-cli",
583 )),
584 profile_id: format!("{provider}-{auth_mode}").into(),
585 provider: provider.into(),
586 purpose: CredentialDeliveryPurpose::ProviderApi,
587 delivery_mode: Some(CredentialDeliveryMode::ProcessEnv),
588 credential_refs: vec![Reference {
589 reference_type: ReferenceType::Credential,
590 uri: format!("runx:credential:local:{material_ref_id}").into(),
591 provider: Some(provider.to_owned().into()),
592 locator: None,
593 label: None,
594 observed_at: None,
595 proof_kind: Some(ProofKind::CredentialResolution),
596 }],
597 material_ref_hash: Some(material_ref_hash.into()),
598 delivered_roles: vec![runx_contracts::CredentialMaterialRole::ApiKey],
599 redaction_refs: None,
600 observed_at: crate::time::now_iso8601().into(),
601 }
602}
603
604fn hash_material_ref(material_ref: &str) -> String {
605 sha256_prefixed(material_ref.as_bytes())
606}
607
608fn require_allowed_binding(
609 decision: &CredentialBindingDecision,
610) -> Result<(), CredentialDeliveryError> {
611 match decision {
612 CredentialBindingDecision::Allow { .. } => Ok(()),
613 CredentialBindingDecision::Deny { reasons } => {
614 Err(CredentialDeliveryError::BindingDenied {
615 reasons: reasons.clone(),
616 })
617 }
618 }
619}
620
621fn apply_profile(
622 profile: &CredentialDeliveryProfile,
623 material: &ResolvedCredentialMaterial,
624) -> Result<SecretEnv, CredentialDeliveryError> {
625 let mut values = BTreeMap::new();
626 for binding in &profile.env_bindings {
627 let Some(secret) = material.values.get(&binding.role) else {
628 if !binding.required {
629 continue;
630 }
631 return Err(CredentialDeliveryError::MissingRole {
632 role: binding.role.label().to_owned(),
633 });
634 };
635 if secret.expose().trim().is_empty() {
636 return Err(CredentialDeliveryError::EmptyMaterial {
637 role: binding.role.label().to_owned(),
638 });
639 }
640 values.insert(binding.env_var.clone(), secret.clone());
641 }
642 Ok(SecretEnv { values })
643}
644
645fn validate_env_name(name: &str) -> Result<(), CredentialDeliveryError> {
646 let mut chars = name.chars();
647 let valid = chars
648 .next()
649 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
650 && chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit());
651 if valid {
652 Ok(())
653 } else {
654 Err(CredentialDeliveryError::InvalidEnvName {
655 name: name.to_owned(),
656 })
657 }
658}
659
660fn truncate_utf8_string(text: &str, limit_bytes: usize) -> String {
661 if text.len() <= limit_bytes {
662 return text.to_owned();
663 }
664 let mut end = limit_bytes;
665 while !text.is_char_boundary(end) {
666 end -= 1;
667 }
668 text[..end].to_owned()
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[test]
676 fn optional_env_binding_is_skipped_when_material_role_is_missing()
677 -> Result<(), CredentialDeliveryError> {
678 let profile = CredentialDeliveryProfile {
679 provider: "github".to_owned(),
680 auth_mode: "api_key".to_owned(),
681 env_bindings: vec![CredentialEnvBinding {
682 role: CredentialMaterialRole::ApiKey,
683 env_var: "GITHUB_TOKEN".to_owned(),
684 required: false,
685 }],
686 };
687 let material = ResolvedCredentialMaterial {
688 material_ref: "secret://github/main".to_owned(),
689 values: BTreeMap::new(),
690 };
691
692 let env = apply_profile(&profile, &material)?;
693
694 assert!(env.is_empty());
695 Ok(())
696 }
697
698 #[test]
699 fn required_env_binding_fails_when_material_role_is_missing() {
700 let profile = CredentialDeliveryProfile {
701 provider: "github".to_owned(),
702 auth_mode: "api_key".to_owned(),
703 env_bindings: vec![CredentialEnvBinding {
704 role: CredentialMaterialRole::ApiKey,
705 env_var: "GITHUB_TOKEN".to_owned(),
706 required: true,
707 }],
708 };
709 let material = ResolvedCredentialMaterial {
710 material_ref: "secret://github/main".to_owned(),
711 values: BTreeMap::new(),
712 };
713
714 let result = apply_profile(&profile, &material);
715
716 assert!(matches!(
717 result,
718 Err(CredentialDeliveryError::MissingRole { role }) if role == "api_key"
719 ));
720 }
721
722 #[test]
723 fn delivery_profile_resolves_non_api_contract_role() -> Result<(), CredentialDeliveryError> {
724 let contract_profile = runx_contracts::CredentialDeliveryProfile {
725 schema: runx_contracts::CredentialDeliveryProfileSchema::V1,
726 profile_id: "github-app".into(),
727 provider: "github".into(),
728 auth_mode: "app".into(),
729 purpose: CredentialDeliveryPurpose::ProviderApi,
730 delivery_mode: CredentialDeliveryMode::ProcessEnv,
731 material_roles: vec![runx_contracts::CredentialMaterialRole::ClientSecret],
732 env_bindings: vec![runx_contracts::CredentialDeliveryEnvBinding {
733 role: runx_contracts::CredentialMaterialRole::ClientSecret,
734 env_var: "GITHUB_CLIENT_SECRET".to_owned(),
735 required: true,
736 }],
737 redaction_policy_ref: Reference::with_uri(
738 ReferenceType::RedactionPolicy,
739 "runx:redaction:credential",
740 ),
741 };
742 let profile = CredentialDeliveryProfile::from_contract_profile(&contract_profile)?;
743 let material = ResolvedCredentialMaterial::with_role(
744 "secret://github/app",
745 CredentialMaterialRole::ClientSecret,
746 "client-secret-value",
747 );
748
749 let env = apply_profile(&profile, &material)?;
750
751 assert_eq!(env.get("GITHUB_CLIENT_SECRET"), Some("client-secret-value"));
752 Ok(())
753 }
754
755 #[test]
756 fn credential_supervisor_resolves_allowed_binding_without_secret_debug_leak()
757 -> Result<(), CredentialDeliveryError> {
758 let material_ref = "secret://github/main";
759 let resolver = InMemoryMaterialResolver::with_material(
760 material_ref,
761 ResolvedCredentialMaterial::api_key(material_ref, "ghp_secret_value"),
762 );
763 let profile = CredentialDeliveryProfile::env_token("github", "api_key", "GITHUB_TOKEN")?;
764 let credential = CredentialEnvelope {
765 kind: CredentialEnvelopeKind::V1,
766 grant_id: "grant_1".into(),
767 provider: "github".into(),
768 auth_mode: "api_key".into(),
769 material_kind: "api_key".into(),
770 provider_reference: "local_per_run".into(),
771 scopes: vec!["repo:read".into()],
772 grant_reference: None,
773 material_ref: material_ref.into(),
774 };
775 let decision = CredentialBindingDecision::Allow {
776 reasons: vec!["unit-test".to_owned()],
777 };
778
779 let delivery = MaterialCredentialSupervisor::new(&resolver)
780 .resolve(CredentialResolutionRequest {
781 decision: &decision,
782 credential: &credential,
783 profile: &profile,
784 observation: build_local_provision_observation("github", "api_key", material_ref),
785 })?
786 .into_delivery();
787
788 assert_eq!(
789 delivery.secret_env().get("GITHUB_TOKEN"),
790 Some("ghp_secret_value")
791 );
792 assert!(!format!("{delivery:?}").contains("ghp_secret_value"));
793 Ok(())
794 }
795
796 #[test]
797 fn local_credential_observation_marks_credential_resolution_proof() -> Result<(), String> {
798 let delivery = CredentialDelivery::from_local_descriptor(
799 "github",
800 "api_key",
801 "GITHUB_TOKEN",
802 "local:github:grant_1",
803 vec!["repo:read".to_owned()],
804 "ghp_secret_value",
805 )
806 .map_err(|error| error.to_string())?;
807 let refs = delivery
808 .credential_refs()
809 .ok_or_else(|| "expected credential refs".to_owned())?;
810
811 assert_eq!(refs.len(), 1);
812 assert_eq!(refs[0].reference_type.as_str(), "credential");
813 assert_eq!(refs[0].provider.as_deref(), Some("github"));
814 assert_eq!(refs[0].proof_kind, Some(ProofKind::CredentialResolution));
815 let observation = delivery
816 .public_observation()
817 .ok_or_else(|| "expected a public observation".to_owned())?;
818 let serialized = serde_json::to_string(observation).map_err(|error| error.to_string())?;
819 assert!(!serialized.contains("ghp_secret_value"));
820 Ok(())
821 }
822
823 #[test]
824 fn hosted_credential_handles_create_non_secret_observation() -> Result<(), String> {
825 let delivery = CredentialDelivery::from_hosted_handles_json(
826 r#"[
827 {
828 "credential_ref": {
829 "type": "credential",
830 "uri": "runx:credential:github-installation:123"
831 },
832 "provider": "github",
833 "purpose": "provider_api"
834 }
835 ]"#,
836 )
837 .map_err(|error| error.to_string())?;
838
839 assert!(delivery.secret_env().is_empty());
840 let observation = delivery
841 .public_observation()
842 .ok_or_else(|| "expected hosted credential observation".to_owned())?;
843 assert_eq!(observation.provider.as_str(), "github");
844 assert_eq!(observation.purpose, CredentialDeliveryPurpose::ProviderApi);
845 assert_eq!(observation.delivery_mode, None);
846 assert!(observation.delivered_roles.is_empty());
847 assert_eq!(observation.credential_refs.len(), 1);
848 assert_eq!(
849 observation.credential_refs[0].proof_kind,
850 Some(ProofKind::CredentialResolution)
851 );
852 assert_eq!(
853 observation.credential_refs[0].provider.as_deref(),
854 Some("github")
855 );
856 Ok(())
857 }
858
859 #[test]
860 fn hosted_credential_handles_fail_closed_on_mixed_authority() {
861 let result = CredentialDelivery::from_hosted_handles_json(
862 r#"[
863 {
864 "credential_ref": {
865 "type": "credential",
866 "uri": "runx:credential:github-installation:123"
867 },
868 "provider": "github",
869 "purpose": "provider_api"
870 },
871 {
872 "credential_ref": {
873 "type": "credential",
874 "uri": "runx:credential:slack:456"
875 },
876 "provider": "slack",
877 "purpose": "provider_api"
878 }
879 ]"#,
880 );
881
882 assert!(matches!(
883 result,
884 Err(CredentialDeliveryError::HostedCredentialHandlesMixed)
885 ));
886 }
887
888 #[test]
889 fn material_ref_errors_report_hashes_not_raw_refs() {
890 let result = InMemoryMaterialResolver::default().resolve_material("secret://github/main");
891 assert!(result.is_err(), "missing material must fail");
892 let missing = match result {
893 Err(error) => error,
894 Ok(_) => return,
895 };
896 let message = missing.to_string();
897 assert!(message.contains("sha256:"));
898 assert!(!message.contains("secret://github/main"));
899
900 let mismatch = CredentialDeliveryError::MaterialRefMismatch {
901 expected_hash: hash_material_ref("secret://github/main"),
902 actual_hash: hash_material_ref("secret://github/other"),
903 };
904 let message = mismatch.to_string();
905 assert!(message.contains("sha256:"));
906 assert!(!message.contains("secret://github/main"));
907 assert!(!message.contains("secret://github/other"));
908 }
909}