Skip to main content

tsafe_core/
contracts.rs

1//! Authority contracts — named, reusable runtime authority definitions.
2//!
3//! Contracts are the next-step policy surface for `tsafe exec`: instead of
4//! spelling policy as a bundle of flags, a repo manifest can declare a named
5//! contract that describes:
6//!
7//! - which vault profile / namespace to resolve from
8//! - which secret names are allowed and required
9//! - which targets may receive injected authority
10//! - which trust posture (`standard`, `hardened`, or explicit `custom`) applies
11//! - future intent such as network policy
12//!
13//! This module is core-only. It parses and validates contract manifests so a
14//! higher layer (CLI, audit UI, future policy engine) can execute against a
15//! stable, auditable model.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use crate::baseline_contracts::{
23    ci_deploy_contract, ops_emergency_contract, read_only_contract, AccessLevel,
24};
25use crate::errors::{SafeError, SafeResult};
26use crate::namespace_bulk::validate_namespace_segment;
27use crate::profile::validate_profile_name;
28use crate::pullconfig::find_config;
29use crate::rbac::RbacProfile;
30use crate::vault::validate_secret_key;
31
32/// A validated named authority contract.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct AuthorityContract {
35    pub name: String,
36    pub profile: Option<String>,
37    pub namespace: Option<String>,
38    pub access_profile: RbacProfile,
39    pub allowed_secrets: Vec<String>,
40    pub required_secrets: Vec<String>,
41    pub allowed_targets: Vec<String>,
42    pub trust: AuthorityTrust,
43    pub network: AuthorityNetworkPolicy,
44}
45
46impl AuthorityContract {
47    /// Resolve the trust posture into a concrete exec policy.
48    pub fn resolved_exec_policy(&self) -> ResolvedAuthorityPolicy {
49        match &self.trust {
50            AuthorityTrust::Standard => ResolvedAuthorityPolicy {
51                trust_level: AuthorityTrustLevel::Standard,
52                access_profile: self.access_profile,
53                inherit: AuthorityInheritMode::Full,
54                deny_dangerous_env: false,
55                redact_output: false,
56            },
57            AuthorityTrust::Hardened => ResolvedAuthorityPolicy {
58                trust_level: AuthorityTrustLevel::Hardened,
59                access_profile: self.access_profile,
60                inherit: AuthorityInheritMode::Minimal,
61                deny_dangerous_env: true,
62                redact_output: true,
63            },
64            AuthorityTrust::Custom(custom) => ResolvedAuthorityPolicy {
65                trust_level: AuthorityTrustLevel::Custom,
66                access_profile: self.access_profile,
67                inherit: custom.inherit,
68                deny_dangerous_env: custom.deny_dangerous_env,
69                redact_output: custom.redact_output,
70            },
71        }
72    }
73
74    /// Return whether this contract allows the given execution target.
75    ///
76    /// Targets are matched exactly against the provided string and, when that
77    /// string is a path, also against the basename. An empty target allowlist
78    /// means "no target restriction yet".
79    pub fn allows_target(&self, command: &str) -> bool {
80        self.evaluate_target(Some(command)).decision.is_allowed()
81    }
82
83    /// Evaluate a target command against this contract's allowlist.
84    ///
85    /// The result is explicit and auditable: higher layers can tell whether a
86    /// command was allowed by exact match, basename match, lack of restriction,
87    /// or denied outright.
88    pub fn evaluate_target(&self, command: Option<&str>) -> AuthorityTargetEvaluation {
89        if self.allowed_targets.is_empty() {
90            return AuthorityTargetEvaluation {
91                decision: AuthorityTargetDecision::Unconstrained,
92                matched_allowlist_entry: None,
93            };
94        }
95
96        let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else {
97            return AuthorityTargetEvaluation {
98                decision: AuthorityTargetDecision::MissingTarget,
99                matched_allowlist_entry: None,
100            };
101        };
102
103        if let Some(matched) = self
104            .allowed_targets
105            .iter()
106            .find(|allowed| allowed == &command)
107        {
108            return AuthorityTargetEvaluation {
109                decision: AuthorityTargetDecision::AllowedExact,
110                matched_allowlist_entry: Some(matched.clone()),
111            };
112        }
113
114        let basename = Path::new(command)
115            .file_name()
116            .and_then(|name| name.to_str())
117            .unwrap_or(command);
118        if let Some(matched) = self
119            .allowed_targets
120            .iter()
121            .find(|allowed| allowed.as_str() == basename)
122        {
123            return AuthorityTargetEvaluation {
124                decision: AuthorityTargetDecision::AllowedBasename,
125                matched_allowlist_entry: Some(matched.clone()),
126            };
127        }
128
129        AuthorityTargetEvaluation {
130            decision: AuthorityTargetDecision::Denied,
131            matched_allowlist_entry: None,
132        }
133    }
134}
135
136/// High-level trust posture for a contract.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum AuthorityTrustLevel {
140    Standard,
141    Hardened,
142    Custom,
143}
144
145impl AuthorityTrustLevel {
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Self::Standard => "standard",
149            Self::Hardened => "hardened",
150            Self::Custom => "custom",
151        }
152    }
153}
154
155/// Resolved trust model for a contract.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum AuthorityTrust {
158    Standard,
159    Hardened,
160    Custom(CustomAuthorityTrust),
161}
162
163/// Explicit trust overrides for `trust_level: custom`.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct CustomAuthorityTrust {
166    pub inherit: AuthorityInheritMode,
167    pub deny_dangerous_env: bool,
168    pub redact_output: bool,
169}
170
171/// Parent-environment inheritance mode for exec authority.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum AuthorityInheritMode {
175    Full,
176    Minimal,
177    Clean,
178}
179
180impl AuthorityInheritMode {
181    pub fn as_str(self) -> &'static str {
182        match self {
183            Self::Full => "full",
184            Self::Minimal => "minimal",
185            Self::Clean => "clean",
186        }
187    }
188}
189
190/// Future network-intent policy for a contract.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum AuthorityNetworkPolicy {
194    #[default]
195    Inherit,
196    Restricted,
197}
198
199impl AuthorityNetworkPolicy {
200    pub fn as_str(self) -> &'static str {
201        match self {
202            Self::Inherit => "inherit",
203            Self::Restricted => "restricted",
204        }
205    }
206}
207
208/// Concrete exec policy derived from a contract trust posture.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct ResolvedAuthorityPolicy {
211    pub trust_level: AuthorityTrustLevel,
212    pub access_profile: RbacProfile,
213    pub inherit: AuthorityInheritMode,
214    pub deny_dangerous_env: bool,
215    pub redact_output: bool,
216}
217
218/// Explicit result of checking a command against a contract allowlist.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum AuthorityTargetDecision {
222    Unconstrained,
223    AllowedExact,
224    AllowedBasename,
225    MissingTarget,
226    Denied,
227}
228
229impl AuthorityTargetDecision {
230    pub fn is_allowed(self) -> bool {
231        matches!(
232            self,
233            Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename
234        )
235    }
236}
237
238/// Outcome of one contract target evaluation, including the allowlist entry
239/// that matched when the decision was positive.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct AuthorityTargetEvaluation {
242    pub decision: AuthorityTargetDecision,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub matched_allowlist_entry: Option<String>,
245}
246
247#[derive(Debug, Default, Deserialize)]
248struct RawContractsFile {
249    #[serde(default)]
250    contracts: BTreeMap<String, RawAuthorityContract>,
251}
252
253#[derive(Debug, Default, Deserialize)]
254struct RawAuthorityContract {
255    /// Optional built-in template to inherit defaults from.
256    /// Available: "read_only", "ci_deploy", "ops_emergency".
257    /// Explicit fields override template defaults.
258    #[serde(default)]
259    template: Option<String>,
260    #[serde(default)]
261    profile: Option<String>,
262    #[serde(default)]
263    namespace: Option<String>,
264    #[serde(default)]
265    access_profile: Option<RbacProfile>,
266    #[serde(default)]
267    allowed_secrets: Vec<String>,
268    #[serde(default)]
269    required_secrets: Vec<String>,
270    #[serde(default)]
271    allowed_targets: Vec<String>,
272    #[serde(default)]
273    trust_level: Option<AuthorityTrustLevel>,
274    #[serde(default)]
275    inherit: Option<AuthorityInheritMode>,
276    #[serde(default)]
277    deny_dangerous_env: Option<bool>,
278    #[serde(default)]
279    redact_output: Option<bool>,
280    #[serde(default)]
281    network: AuthorityNetworkPolicy,
282}
283
284/// Search upward from `start` for a repo manifest that may contain contracts.
285pub fn find_contracts_manifest(start: &Path) -> Option<PathBuf> {
286    find_config(start)
287}
288
289/// Load and validate all authority contracts from a repo manifest file.
290///
291/// The manifest may also contain other top-level sections (for example `pulls`);
292/// this loader ignores everything except `contracts`.
293pub fn load_contracts(path: &Path) -> SafeResult<BTreeMap<String, AuthorityContract>> {
294    let content = std::fs::read_to_string(path)?;
295    let raw: RawContractsFile = if is_json(path) {
296        serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
297            reason: format!("invalid authority contract JSON: {e}"),
298        })?
299    } else {
300        serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
301            reason: format!("invalid authority contract YAML: {e}"),
302        })?
303    };
304
305    raw.contracts
306        .into_iter()
307        .map(|(name, raw)| Ok((name.clone(), validate_contract(name, raw)?)))
308        .collect()
309}
310
311/// Load a single named authority contract from a manifest file.
312pub fn load_contract(path: &Path, name: &str) -> SafeResult<AuthorityContract> {
313    let contracts = load_contracts(path)?;
314    contracts
315        .get(name)
316        .cloned()
317        .ok_or_else(|| SafeError::InvalidVault {
318            reason: format!(
319                "authority contract '{name}' not found in {}",
320                path.display()
321            ),
322        })
323}
324
325fn apply_template_defaults(contract_name: &str, raw: &mut RawAuthorityContract) -> SafeResult<()> {
326    let tmpl_name = match raw.template.as_deref() {
327        Some(n) => n,
328        None => return Ok(()),
329    };
330    let baseline = match tmpl_name {
331        "read_only" => read_only_contract(),
332        "ci_deploy" => ci_deploy_contract(vec!["terraform".to_string(), "ansible".to_string()]),
333        "ops_emergency" => ops_emergency_contract(),
334        other => {
335            return Err(SafeError::InvalidVault {
336                reason: format!(
337                    "contract '{contract_name}': unknown template '{other}' \
338                     (available: read_only, ci_deploy, ops_emergency)"
339                ),
340            })
341        }
342    };
343    // Fill in unset fields from the template. Explicit YAML fields win.
344    if raw.trust_level.is_none() {
345        raw.trust_level = Some(match baseline.required_trust_profile.as_str() {
346            "hardened" => AuthorityTrustLevel::Hardened,
347            _ => AuthorityTrustLevel::Standard,
348        });
349    }
350    if raw.access_profile.is_none() {
351        raw.access_profile = Some(match baseline.access_level {
352            AccessLevel::ReadOnly => RbacProfile::ReadOnly,
353            AccessLevel::ReadWrite => RbacProfile::ReadWrite,
354        });
355    }
356    if raw.allowed_secrets.is_empty() {
357        raw.allowed_secrets = baseline.secret_constraints.allowed_secrets;
358    }
359    if raw.required_secrets.is_empty() {
360        raw.required_secrets = baseline.secret_constraints.required_secrets;
361    }
362    if raw.allowed_targets.is_empty() {
363        if let Some(targets) = baseline.target_constraints {
364            raw.allowed_targets = targets;
365        }
366    }
367    Ok(())
368}
369
370fn validate_contract(name: String, mut raw: RawAuthorityContract) -> SafeResult<AuthorityContract> {
371    validate_contract_name(&name)?;
372    apply_template_defaults(&name, &mut raw)?;
373
374    let trust_level = raw.trust_level.ok_or_else(|| SafeError::InvalidVault {
375        reason: format!("contract '{name}': trust_level is required (or use template: <name>)"),
376    })?;
377    if matches!(
378        trust_level,
379        AuthorityTrustLevel::Standard | AuthorityTrustLevel::Hardened
380    ) {
381        reject_custom_overrides(&name, &raw)?;
382    }
383
384    let profile = raw.profile.map(|profile| profile.trim().to_string());
385    if let Some(profile) = profile.as_deref() {
386        validate_profile_name(profile)?;
387    }
388
389    let namespace = raw.namespace.map(|namespace| namespace.trim().to_string());
390    if let Some(namespace) = namespace.as_deref() {
391        validate_namespace_segment(namespace)?;
392    }
393
394    let allowed_secrets =
395        normalize_contract_secret_names(&name, "allowed_secrets", raw.allowed_secrets)?;
396    let required_secrets =
397        normalize_contract_secret_names(&name, "required_secrets", raw.required_secrets)?;
398    for secret in &required_secrets {
399        if !allowed_secrets.iter().any(|allowed| allowed == secret) {
400            return Err(SafeError::InvalidVault {
401                reason: format!(
402                    "contract '{name}': required secret '{secret}' must also appear in allowed_secrets"
403                ),
404            });
405        }
406    }
407
408    let trust = match trust_level {
409        AuthorityTrustLevel::Standard => AuthorityTrust::Standard,
410        AuthorityTrustLevel::Hardened => AuthorityTrust::Hardened,
411        AuthorityTrustLevel::Custom => AuthorityTrust::Custom(CustomAuthorityTrust {
412            inherit: raw.inherit.unwrap_or(AuthorityInheritMode::Full),
413            deny_dangerous_env: raw.deny_dangerous_env.unwrap_or(false),
414            redact_output: raw.redact_output.unwrap_or(false),
415        }),
416    };
417    let allowed_targets = normalize_allowed_targets(&name, raw.allowed_targets)?;
418
419    Ok(AuthorityContract {
420        name,
421        profile,
422        namespace,
423        access_profile: raw.access_profile.unwrap_or_default(),
424        allowed_secrets,
425        required_secrets,
426        allowed_targets,
427        trust,
428        network: raw.network,
429    })
430}
431
432fn reject_custom_overrides(name: &str, raw: &RawAuthorityContract) -> SafeResult<()> {
433    if raw.inherit.is_some() || raw.deny_dangerous_env.is_some() || raw.redact_output.is_some() {
434        return Err(SafeError::InvalidVault {
435            reason: format!(
436                "contract '{name}': inherit / deny_dangerous_env / redact_output are only valid with trust_level: custom"
437            ),
438        });
439    }
440    Ok(())
441}
442
443fn normalize_contract_secret_names(
444    contract_name: &str,
445    field: &str,
446    names: Vec<String>,
447) -> SafeResult<Vec<String>> {
448    let mut out = Vec::new();
449    for name in names {
450        let trimmed = name.trim();
451        if trimmed.is_empty() {
452            return Err(SafeError::InvalidVault {
453                reason: format!("contract '{contract_name}': {field} contains an empty name"),
454            });
455        }
456        if trimmed.contains('/') {
457            return Err(SafeError::InvalidVault {
458                reason: format!(
459                    "contract '{contract_name}': {field} entry '{trimmed}' must be a post-namespace secret/env name, not a namespaced vault key"
460                ),
461            });
462        }
463        validate_secret_key(trimmed)?;
464        if !out.iter().any(|existing: &String| existing == trimmed) {
465            out.push(trimmed.to_string());
466        }
467    }
468    out.sort();
469    Ok(out)
470}
471
472fn normalize_allowed_targets(contract_name: &str, targets: Vec<String>) -> SafeResult<Vec<String>> {
473    let mut out = Vec::new();
474    for target in targets {
475        let trimmed = target.trim();
476        if trimmed.is_empty() {
477            return Err(SafeError::InvalidVault {
478                reason: format!(
479                    "contract '{contract_name}': allowed_targets contains an empty target"
480                ),
481            });
482        }
483        if trimmed.chars().any(char::is_control) {
484            return Err(SafeError::InvalidVault {
485                reason: format!(
486                    "contract '{contract_name}': target '{trimmed}' contains control characters"
487                ),
488            });
489        }
490        if !out.iter().any(|existing: &String| existing == trimmed) {
491            out.push(trimmed.to_string());
492        }
493    }
494    out.sort();
495    Ok(out)
496}
497
498fn validate_contract_name(name: &str) -> SafeResult<()> {
499    if name.is_empty() {
500        return Err(SafeError::InvalidVault {
501            reason: "contract name cannot be empty".into(),
502        });
503    }
504    if !name
505        .chars()
506        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
507    {
508        return Err(SafeError::InvalidVault {
509            reason: format!(
510                "contract '{name}': only ASCII letters, digits, '-', '_' and '.' are allowed"
511            ),
512        });
513    }
514    Ok(())
515}
516
517fn is_json(path: &Path) -> bool {
518    path.extension()
519        .and_then(|e| e.to_str())
520        .map(|e| e == "json")
521        .unwrap_or(false)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use tempfile::tempdir;
528
529    #[test]
530    fn parse_yaml_contracts_ignores_other_manifest_sections() {
531        let yaml = r#"
532pulls:
533  - source: akv
534    vault_url: https://example.vault.azure.net
535contracts:
536  deploy:
537    profile: work
538    namespace: infra
539    allowed_secrets:
540      - DB_PASSWORD
541      - API_KEY
542    required_secrets:
543      - DB_PASSWORD
544    allowed_targets:
545      - terraform
546      - /usr/bin/tofu
547    access_profile: read_only
548    trust_level: hardened
549    network: restricted
550"#;
551        let dir = tempdir().unwrap();
552        let path = dir.path().join(".tsafe.yml");
553        std::fs::write(&path, yaml).unwrap();
554
555        let contracts = load_contracts(&path).unwrap();
556        let deploy = contracts.get("deploy").unwrap();
557        assert_eq!(deploy.profile.as_deref(), Some("work"));
558        assert_eq!(deploy.namespace.as_deref(), Some("infra"));
559        assert_eq!(deploy.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
560        assert_eq!(deploy.required_secrets, vec!["DB_PASSWORD"]);
561        assert_eq!(deploy.allowed_targets, vec!["/usr/bin/tofu", "terraform"]);
562        assert_eq!(deploy.access_profile, RbacProfile::ReadOnly);
563        assert_eq!(deploy.network, AuthorityNetworkPolicy::Restricted);
564        assert_eq!(
565            deploy.resolved_exec_policy().access_profile,
566            RbacProfile::ReadOnly
567        );
568        assert_eq!(
569            deploy.resolved_exec_policy().inherit,
570            AuthorityInheritMode::Minimal
571        );
572        assert!(deploy.resolved_exec_policy().deny_dangerous_env);
573        assert!(deploy.resolved_exec_policy().redact_output);
574    }
575
576    #[test]
577    fn parse_json_contract_with_custom_trust() {
578        let json = r#"{
579  "contracts": {
580    "deploy": {
581      "allowed_secrets": ["DB_PASSWORD", "DB_PASSWORD"],
582      "required_secrets": ["DB_PASSWORD"],
583      "trust_level": "custom",
584      "inherit": "clean",
585      "deny_dangerous_env": true,
586      "redact_output": true
587    }
588  }
589}"#;
590        let dir = tempdir().unwrap();
591        let path = dir.path().join(".tsafe.json");
592        std::fs::write(&path, json).unwrap();
593
594        let deploy = load_contract(&path, "deploy").unwrap();
595        assert_eq!(deploy.allowed_secrets, vec!["DB_PASSWORD"]);
596        assert_eq!(
597            deploy.trust,
598            AuthorityTrust::Custom(CustomAuthorityTrust {
599                inherit: AuthorityInheritMode::Clean,
600                deny_dangerous_env: true,
601                redact_output: true,
602            })
603        );
604    }
605
606    #[test]
607    fn missing_trust_level_is_rejected() {
608        let yaml = r#"
609contracts:
610  deploy:
611    allowed_secrets: [DB_PASSWORD]
612"#;
613        let dir = tempdir().unwrap();
614        let path = dir.path().join(".tsafe.yml");
615        std::fs::write(&path, yaml).unwrap();
616
617        let err = load_contracts(&path).unwrap_err();
618        assert!(matches!(
619            err,
620            SafeError::InvalidVault { ref reason } if reason.contains("trust_level is required")
621        ));
622    }
623
624    #[test]
625    fn standard_or_hardened_reject_custom_overrides() {
626        let yaml = r#"
627contracts:
628  deploy:
629    allowed_secrets: [DB_PASSWORD]
630    trust_level: hardened
631    inherit: clean
632"#;
633        let dir = tempdir().unwrap();
634        let path = dir.path().join(".tsafe.yml");
635        std::fs::write(&path, yaml).unwrap();
636
637        let err = load_contracts(&path).unwrap_err();
638        assert!(matches!(
639            err,
640            SafeError::InvalidVault { ref reason }
641                if reason.contains("only valid with trust_level: custom")
642        ));
643    }
644
645    #[test]
646    fn required_secrets_must_be_allowed() {
647        let yaml = r#"
648contracts:
649  deploy:
650    allowed_secrets: [API_KEY]
651    required_secrets: [DB_PASSWORD]
652    trust_level: standard
653"#;
654        let dir = tempdir().unwrap();
655        let path = dir.path().join(".tsafe.yml");
656        std::fs::write(&path, yaml).unwrap();
657
658        let err = load_contracts(&path).unwrap_err();
659        assert!(matches!(
660            err,
661            SafeError::InvalidVault { ref reason }
662                if reason.contains("must also appear in allowed_secrets")
663        ));
664    }
665
666    #[test]
667    fn evaluate_target_returns_explicit_decisions() {
668        let contract = AuthorityContract {
669            name: "deploy".into(),
670            profile: None,
671            namespace: None,
672            access_profile: RbacProfile::ReadWrite,
673            allowed_secrets: vec!["DB_PASSWORD".into()],
674            required_secrets: vec!["DB_PASSWORD".into()],
675            allowed_targets: vec!["terraform".into(), "/usr/bin/tofu".into()],
676            trust: AuthorityTrust::Hardened,
677            network: AuthorityNetworkPolicy::Inherit,
678        };
679
680        assert_eq!(
681            contract.evaluate_target(Some("terraform")),
682            AuthorityTargetEvaluation {
683                decision: AuthorityTargetDecision::AllowedExact,
684                matched_allowlist_entry: Some("terraform".into()),
685            }
686        );
687        assert_eq!(
688            contract.evaluate_target(Some("/usr/local/bin/terraform")),
689            AuthorityTargetEvaluation {
690                decision: AuthorityTargetDecision::AllowedBasename,
691                matched_allowlist_entry: Some("terraform".into()),
692            }
693        );
694        assert_eq!(
695            contract.evaluate_target(Some("/usr/bin/tofu")),
696            AuthorityTargetEvaluation {
697                decision: AuthorityTargetDecision::AllowedExact,
698                matched_allowlist_entry: Some("/usr/bin/tofu".into()),
699            }
700        );
701        assert_eq!(
702            contract.evaluate_target(Some("bash")),
703            AuthorityTargetEvaluation {
704                decision: AuthorityTargetDecision::Denied,
705                matched_allowlist_entry: None,
706            }
707        );
708        assert_eq!(
709            contract.evaluate_target(None),
710            AuthorityTargetEvaluation {
711                decision: AuthorityTargetDecision::MissingTarget,
712                matched_allowlist_entry: None,
713            }
714        );
715        assert!(contract.allows_target("terraform"));
716        assert!(!contract.allows_target("bash"));
717    }
718
719    #[test]
720    fn evaluate_target_is_unconstrained_without_allowlist() {
721        let contract = AuthorityContract {
722            name: "deploy".into(),
723            profile: None,
724            namespace: None,
725            access_profile: RbacProfile::ReadWrite,
726            allowed_secrets: vec!["DB_PASSWORD".into()],
727            required_secrets: vec!["DB_PASSWORD".into()],
728            allowed_targets: Vec::new(),
729            trust: AuthorityTrust::Standard,
730            network: AuthorityNetworkPolicy::Inherit,
731        };
732
733        assert_eq!(
734            contract.evaluate_target(None),
735            AuthorityTargetEvaluation {
736                decision: AuthorityTargetDecision::Unconstrained,
737                matched_allowlist_entry: None,
738            }
739        );
740    }
741
742    #[test]
743    fn find_contracts_manifest_uses_repo_search_rules() {
744        let dir = tempdir().unwrap();
745        let nested = dir.path().join("a/b/c");
746        std::fs::create_dir_all(&nested).unwrap();
747        let manifest = dir.path().join(".tsafe.yml");
748        std::fs::write(&manifest, "contracts: {}").unwrap();
749
750        assert_eq!(find_contracts_manifest(&nested), Some(manifest));
751    }
752
753    #[test]
754    fn contracts_default_to_read_write_access_profile() {
755        let yaml = r#"
756contracts:
757  deploy:
758    allowed_secrets: [DB_PASSWORD]
759    trust_level: standard
760"#;
761        let dir = tempdir().unwrap();
762        let path = dir.path().join(".tsafe.yml");
763        std::fs::write(&path, yaml).unwrap();
764
765        let deploy = load_contract(&path, "deploy").unwrap();
766        assert_eq!(deploy.access_profile, RbacProfile::ReadWrite);
767        assert_eq!(
768            deploy.resolved_exec_policy().access_profile,
769            RbacProfile::ReadWrite
770        );
771    }
772}