Skip to main content

runx_runtime/
credentials.rs

1// rust-style-allow: large-file - credential delivery is one secret-handling trust surface; secret
2// string/env types, redaction, material resolution, and the delivery boundary stay colocated so the
3// "secrets never leak" review happens against the whole module at once.
4use 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    /// The non-secret observation recording this delivery. Required so a resolved
127    /// secret can never be delivered without its audit record on the receipt.
128    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    /// Build a delivery from a one-shot, per-run local credential descriptor.
313    ///
314    /// This is the OSS local-provision path: no network, no persistence, no
315    /// brokerage. It derives a delivery profile, a credential envelope, and an
316    /// allowed binding decision purely from the supplied descriptor, resolves
317    /// the secret in-memory, and routes it through the same
318    /// [`Self::from_allowed_binding`] seam so policy checks and redaction stay
319    /// centralized. The secret value is held only for the lifetime of this run.
320    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        // Captured before the values move into the envelope/resolver below, so
333        // the run records a non-secret observation of the local provision.
334        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    // rust-style-allow: long-function because hosted handle delivery validates
370    // one homogeneous credential batch before exposing any secret references.
371    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
558/// Build the non-secret observation that records a local per-run credential
559/// provision on the sealed receipt. It carries no secret material: only the
560/// provider, profile, scoped credential reference, and a hash of the opaque
561/// material ref. The timestamp is captured at observation time because local
562/// credential provision is a live trust boundary, not a fixture surface.
563fn 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}