1use 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::deny_reason::DenyReason;
26use crate::errors::{SafeError, SafeResult};
27use crate::namespace_bulk::validate_namespace_segment;
28use crate::profile::validate_profile_name;
29use crate::pullconfig::find_config;
30use crate::rbac::RbacProfile;
31use crate::vault::validate_secret_key;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthorityContract {
36 pub name: String,
37 pub profile: Option<String>,
38 pub namespace: Option<String>,
39 pub access_profile: RbacProfile,
40 pub allow_all_secrets: bool,
41 pub allowed_secrets: Vec<String>,
42 pub required_secrets: Vec<String>,
43 pub allowed_targets: Vec<String>,
44 pub trust: AuthorityTrust,
45 pub network: AuthorityNetworkPolicy,
46}
47
48impl AuthorityContract {
49 pub fn resolved_exec_policy(&self) -> ResolvedAuthorityPolicy {
51 match &self.trust {
52 AuthorityTrust::Standard => ResolvedAuthorityPolicy {
53 trust_level: AuthorityTrustLevel::Standard,
54 access_profile: self.access_profile,
55 inherit: AuthorityInheritMode::Full,
56 deny_dangerous_env: false,
57 redact_output: false,
58 },
59 AuthorityTrust::Hardened => ResolvedAuthorityPolicy {
60 trust_level: AuthorityTrustLevel::Hardened,
61 access_profile: self.access_profile,
62 inherit: AuthorityInheritMode::Minimal,
63 deny_dangerous_env: true,
64 redact_output: true,
65 },
66 AuthorityTrust::Custom(custom) => ResolvedAuthorityPolicy {
67 trust_level: AuthorityTrustLevel::Custom,
68 access_profile: self.access_profile,
69 inherit: custom.inherit,
70 deny_dangerous_env: custom.deny_dangerous_env,
71 redact_output: custom.redact_output,
72 },
73 }
74 }
75
76 pub fn requires_zero_secrets(&self) -> bool {
78 self.required_secrets.is_empty()
79 }
80
81 pub fn injects_zero_secrets(&self) -> bool {
86 !self.allow_all_secrets && self.allowed_secrets.is_empty()
87 }
88
89 pub fn is_no_secret_diagnostic_contract(&self) -> bool {
92 self.requires_zero_secrets() && self.injects_zero_secrets()
93 }
94
95 pub fn allows_target(&self, command: &str) -> bool {
101 self.evaluate_target(Some(command)).decision.is_allowed()
102 }
103
104 pub fn evaluate_target(&self, command: Option<&str>) -> AuthorityTargetEvaluation {
110 if self.allowed_targets.is_empty() {
111 return AuthorityTargetEvaluation {
112 decision: AuthorityTargetDecision::Unconstrained,
113 matched_allowlist_entry: None,
114 };
115 }
116
117 let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else {
118 return AuthorityTargetEvaluation {
119 decision: AuthorityTargetDecision::MissingTarget,
120 matched_allowlist_entry: None,
121 };
122 };
123
124 if let Some(matched) = self
125 .allowed_targets
126 .iter()
127 .find(|allowed| allowed == &command)
128 {
129 return AuthorityTargetEvaluation {
130 decision: AuthorityTargetDecision::AllowedExact,
131 matched_allowlist_entry: Some(matched.clone()),
132 };
133 }
134
135 let basename = Path::new(command)
136 .file_name()
137 .and_then(|name| name.to_str())
138 .unwrap_or(command);
139 if let Some(matched) = self
140 .allowed_targets
141 .iter()
142 .find(|allowed| allowed.as_str() == basename)
143 {
144 return AuthorityTargetEvaluation {
145 decision: AuthorityTargetDecision::AllowedBasename,
146 matched_allowlist_entry: Some(matched.clone()),
147 };
148 }
149
150 AuthorityTargetEvaluation {
151 decision: AuthorityTargetDecision::Denied,
152 matched_allowlist_entry: None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum BoundFirewallTargetPolicyError {
160 EmptyTargetAllowlist,
161}
162
163impl BoundFirewallTargetPolicyError {
164 pub fn deny_reason(self) -> DenyReason {
166 match self {
167 Self::EmptyTargetAllowlist => DenyReason::BlankScope,
168 }
169 }
170}
171
172pub fn validate_bound_firewall_target_policy(
179 contract: &AuthorityContract,
180) -> Result<(), BoundFirewallTargetPolicyError> {
181 if contract.allowed_targets.is_empty() {
182 return Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist);
183 }
184 Ok(())
185}
186
187pub fn validate_bound_firewall_network_policy(
189 contract: &AuthorityContract,
190 network_restriction_enforceable: bool,
191) -> Result<(), DenyReason> {
192 if matches!(contract.network, AuthorityNetworkPolicy::Restricted)
193 && !network_restriction_enforceable
194 {
195 return Err(DenyReason::NetworkUnenforced);
196 }
197 Ok(())
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum AuthorityTrustLevel {
204 Standard,
205 Hardened,
206 Custom,
207}
208
209impl AuthorityTrustLevel {
210 pub fn as_str(self) -> &'static str {
211 match self {
212 Self::Standard => "standard",
213 Self::Hardened => "hardened",
214 Self::Custom => "custom",
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum AuthorityTrust {
222 Standard,
223 Hardened,
224 Custom(CustomAuthorityTrust),
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CustomAuthorityTrust {
230 pub inherit: AuthorityInheritMode,
231 pub deny_dangerous_env: bool,
232 pub redact_output: bool,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum AuthorityInheritMode {
239 Full,
240 Minimal,
241 Clean,
242}
243
244impl AuthorityInheritMode {
245 pub fn as_str(self) -> &'static str {
246 match self {
247 Self::Full => "full",
248 Self::Minimal => "minimal",
249 Self::Clean => "clean",
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum AuthorityNetworkPolicy {
258 #[default]
259 Inherit,
260 Restricted,
261}
262
263impl AuthorityNetworkPolicy {
264 pub fn as_str(self) -> &'static str {
265 match self {
266 Self::Inherit => "inherit",
267 Self::Restricted => "restricted",
268 }
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct ResolvedAuthorityPolicy {
275 pub trust_level: AuthorityTrustLevel,
276 pub access_profile: RbacProfile,
277 pub inherit: AuthorityInheritMode,
278 pub deny_dangerous_env: bool,
279 pub redact_output: bool,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "snake_case")]
285pub enum AuthorityTargetDecision {
286 Unconstrained,
287 AllowedExact,
288 AllowedBasename,
289 MissingTarget,
290 Denied,
291}
292
293impl AuthorityTargetDecision {
294 pub fn is_allowed(self) -> bool {
295 matches!(
296 self,
297 Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename
298 )
299 }
300
301 pub fn deny_reason(self) -> Option<DenyReason> {
303 match self {
304 Self::Denied => Some(DenyReason::TargetNotAllowed),
305 Self::MissingTarget => Some(DenyReason::TargetMissing),
306 Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename => None,
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct AuthorityTargetEvaluation {
315 pub decision: AuthorityTargetDecision,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub matched_allowlist_entry: Option<String>,
318}
319
320#[derive(Debug, Default, Deserialize)]
321struct RawContractsFile {
322 #[serde(default)]
323 contracts: BTreeMap<String, RawAuthorityContract>,
324}
325
326#[derive(Debug, Default, Deserialize)]
327struct RawAuthorityContract {
328 #[serde(default)]
332 template: Option<String>,
333 #[serde(default)]
334 profile: Option<String>,
335 #[serde(default)]
336 namespace: Option<String>,
337 #[serde(default)]
338 access_profile: Option<RbacProfile>,
339 #[serde(default)]
340 allowed_secrets: Option<Vec<String>>,
341 #[serde(default)]
342 required_secrets: Vec<String>,
343 #[serde(default)]
344 allowed_targets: Vec<String>,
345 #[serde(default)]
346 trust_level: Option<AuthorityTrustLevel>,
347 #[serde(default)]
348 inherit: Option<AuthorityInheritMode>,
349 #[serde(default)]
350 deny_dangerous_env: Option<bool>,
351 #[serde(default)]
352 redact_output: Option<bool>,
353 #[serde(default)]
354 network: AuthorityNetworkPolicy,
355}
356
357pub fn find_contracts_manifest(start: &Path) -> Option<PathBuf> {
359 find_config(start)
360}
361
362pub fn load_contracts(path: &Path) -> SafeResult<BTreeMap<String, AuthorityContract>> {
367 let content = std::fs::read_to_string(path)?;
368 let raw: RawContractsFile = if is_json(path) {
369 serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
370 reason: format!("invalid authority contract JSON: {e}"),
371 })?
372 } else {
373 serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
374 reason: format!("invalid authority contract YAML: {e}"),
375 })?
376 };
377
378 raw.contracts
379 .into_iter()
380 .map(|(name, raw)| Ok((name.clone(), validate_contract(name, raw)?)))
381 .collect()
382}
383
384pub fn load_contract(path: &Path, name: &str) -> SafeResult<AuthorityContract> {
386 let contracts = load_contracts(path)?;
387 contracts
388 .get(name)
389 .cloned()
390 .ok_or_else(|| SafeError::InvalidVault {
391 reason: format!(
392 "authority contract '{name}' not found in {}",
393 path.display()
394 ),
395 })
396}
397
398fn apply_template_defaults(contract_name: &str, raw: &mut RawAuthorityContract) -> SafeResult<()> {
399 let tmpl_name = match raw.template.as_deref() {
400 Some(n) => n,
401 None => return Ok(()),
402 };
403 let baseline = match tmpl_name {
404 "read_only" => read_only_contract(),
405 "ci_deploy" => ci_deploy_contract(vec!["terraform".to_string(), "ansible".to_string()]),
406 "ops_emergency" => ops_emergency_contract(),
407 other => {
408 return Err(SafeError::InvalidVault {
409 reason: format!(
410 "contract '{contract_name}': unknown template '{other}' \
411 (available: read_only, ci_deploy, ops_emergency)"
412 ),
413 })
414 }
415 };
416 if raw.trust_level.is_none() {
418 raw.trust_level = Some(match baseline.required_trust_profile.as_str() {
419 "hardened" => AuthorityTrustLevel::Hardened,
420 _ => AuthorityTrustLevel::Standard,
421 });
422 }
423 if raw.access_profile.is_none() {
424 raw.access_profile = Some(match baseline.access_level {
425 AccessLevel::ReadOnly => RbacProfile::ReadOnly,
426 AccessLevel::ReadWrite => RbacProfile::ReadWrite,
427 });
428 }
429 if raw.allowed_secrets.is_none() {
430 raw.allowed_secrets = Some(baseline.secret_constraints.allowed_secrets);
431 }
432 if raw.required_secrets.is_empty() {
433 raw.required_secrets = baseline.secret_constraints.required_secrets;
434 }
435 if raw.allowed_targets.is_empty() {
436 if let Some(targets) = baseline.target_constraints {
437 raw.allowed_targets = targets;
438 }
439 }
440 Ok(())
441}
442
443fn validate_contract(name: String, mut raw: RawAuthorityContract) -> SafeResult<AuthorityContract> {
444 validate_contract_name(&name)?;
445 let allowed_secrets_was_specified = raw.allowed_secrets.is_some();
446 apply_template_defaults(&name, &mut raw)?;
447
448 let trust_level = raw.trust_level.ok_or_else(|| SafeError::InvalidVault {
449 reason: format!("contract '{name}': trust_level is required (or use template: <name>)"),
450 })?;
451 if matches!(
452 trust_level,
453 AuthorityTrustLevel::Standard | AuthorityTrustLevel::Hardened
454 ) {
455 reject_custom_overrides(&name, &raw)?;
456 }
457
458 let profile = raw.profile.map(|profile| profile.trim().to_string());
459 if let Some(profile) = profile.as_deref() {
460 validate_profile_name(profile)?;
461 }
462
463 let namespace = raw.namespace.map(|namespace| namespace.trim().to_string());
464 if let Some(namespace) = namespace.as_deref() {
465 validate_namespace_segment(namespace)?;
466 }
467
468 let allowed_secrets = normalize_contract_secret_names(
469 &name,
470 "allowed_secrets",
471 raw.allowed_secrets.unwrap_or_default(),
472 )?;
473 let allow_all_secrets = !allowed_secrets_was_specified && allowed_secrets.is_empty();
474 let required_secrets =
475 normalize_contract_secret_names(&name, "required_secrets", raw.required_secrets)?;
476 for secret in &required_secrets {
477 if !allow_all_secrets && !allowed_secrets.iter().any(|allowed| allowed == secret) {
478 return Err(SafeError::InvalidVault {
479 reason: format!(
480 "contract '{name}': required secret '{secret}' must also appear in allowed_secrets"
481 ),
482 });
483 }
484 }
485
486 let trust = match trust_level {
487 AuthorityTrustLevel::Standard => AuthorityTrust::Standard,
488 AuthorityTrustLevel::Hardened => AuthorityTrust::Hardened,
489 AuthorityTrustLevel::Custom => AuthorityTrust::Custom(CustomAuthorityTrust {
490 inherit: raw.inherit.unwrap_or(AuthorityInheritMode::Full),
491 deny_dangerous_env: raw.deny_dangerous_env.unwrap_or(false),
492 redact_output: raw.redact_output.unwrap_or(false),
493 }),
494 };
495 let allowed_targets = normalize_allowed_targets(&name, raw.allowed_targets)?;
496
497 Ok(AuthorityContract {
498 name,
499 profile,
500 namespace,
501 access_profile: raw.access_profile.unwrap_or_default(),
502 allow_all_secrets,
503 allowed_secrets,
504 required_secrets,
505 allowed_targets,
506 trust,
507 network: raw.network,
508 })
509}
510
511fn reject_custom_overrides(name: &str, raw: &RawAuthorityContract) -> SafeResult<()> {
512 if raw.inherit.is_some() || raw.deny_dangerous_env.is_some() || raw.redact_output.is_some() {
513 return Err(SafeError::InvalidVault {
514 reason: format!(
515 "contract '{name}': inherit / deny_dangerous_env / redact_output are only valid with trust_level: custom"
516 ),
517 });
518 }
519 Ok(())
520}
521
522fn normalize_contract_secret_names(
523 contract_name: &str,
524 field: &str,
525 names: Vec<String>,
526) -> SafeResult<Vec<String>> {
527 let mut out = Vec::new();
528 for name in names {
529 let trimmed = name.trim();
530 if trimmed.is_empty() {
531 return Err(SafeError::InvalidVault {
532 reason: format!("contract '{contract_name}': {field} contains an empty name"),
533 });
534 }
535 if trimmed.contains('/') {
536 return Err(SafeError::InvalidVault {
537 reason: format!(
538 "contract '{contract_name}': {field} entry '{trimmed}' must be a post-namespace secret/env name, not a namespaced vault key"
539 ),
540 });
541 }
542 validate_secret_key(trimmed)?;
543 if !out.iter().any(|existing: &String| existing == trimmed) {
544 out.push(trimmed.to_string());
545 }
546 }
547 out.sort();
548 Ok(out)
549}
550
551fn normalize_allowed_targets(contract_name: &str, targets: Vec<String>) -> SafeResult<Vec<String>> {
552 let mut out = Vec::new();
553 for target in targets {
554 let trimmed = target.trim();
555 if trimmed.is_empty() {
556 return Err(SafeError::InvalidVault {
557 reason: format!(
558 "contract '{contract_name}': allowed_targets contains an empty target"
559 ),
560 });
561 }
562 if trimmed.chars().any(char::is_control) {
563 return Err(SafeError::InvalidVault {
564 reason: format!(
565 "contract '{contract_name}': target '{trimmed}' contains control characters"
566 ),
567 });
568 }
569 if !out.iter().any(|existing: &String| existing == trimmed) {
570 out.push(trimmed.to_string());
571 }
572 }
573 out.sort();
574 Ok(out)
575}
576
577fn validate_contract_name(name: &str) -> SafeResult<()> {
578 if name.is_empty() {
579 return Err(SafeError::InvalidVault {
580 reason: "contract name cannot be empty".into(),
581 });
582 }
583 if !name
584 .chars()
585 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
586 {
587 return Err(SafeError::InvalidVault {
588 reason: format!(
589 "contract '{name}': only ASCII letters, digits, '-', '_' and '.' are allowed"
590 ),
591 });
592 }
593 Ok(())
594}
595
596fn is_json(path: &Path) -> bool {
597 path.extension()
598 .and_then(|e| e.to_str())
599 .map(|e| e == "json")
600 .unwrap_or(false)
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use tempfile::tempdir;
607
608 #[test]
609 fn parse_yaml_contracts_ignores_other_manifest_sections() {
610 let yaml = r#"
611pulls:
612 - source: akv
613 vault_url: https://example.vault.azure.net
614contracts:
615 deploy:
616 profile: work
617 namespace: infra
618 allowed_secrets:
619 - DB_PASSWORD
620 - API_KEY
621 required_secrets:
622 - DB_PASSWORD
623 allowed_targets:
624 - terraform
625 - /usr/bin/tofu
626 access_profile: read_only
627 trust_level: hardened
628 network: restricted
629"#;
630 let dir = tempdir().unwrap();
631 let path = dir.path().join(".tsafe.yml");
632 std::fs::write(&path, yaml).unwrap();
633
634 let contracts = load_contracts(&path).unwrap();
635 let deploy = contracts.get("deploy").unwrap();
636 assert_eq!(deploy.profile.as_deref(), Some("work"));
637 assert_eq!(deploy.namespace.as_deref(), Some("infra"));
638 assert_eq!(deploy.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
639 assert_eq!(deploy.required_secrets, vec!["DB_PASSWORD"]);
640 assert_eq!(deploy.allowed_targets, vec!["/usr/bin/tofu", "terraform"]);
641 assert_eq!(deploy.access_profile, RbacProfile::ReadOnly);
642 assert_eq!(deploy.network, AuthorityNetworkPolicy::Restricted);
643 assert_eq!(
644 deploy.resolved_exec_policy().access_profile,
645 RbacProfile::ReadOnly
646 );
647 assert_eq!(
648 deploy.resolved_exec_policy().inherit,
649 AuthorityInheritMode::Minimal
650 );
651 assert!(deploy.resolved_exec_policy().deny_dangerous_env);
652 assert!(deploy.resolved_exec_policy().redact_output);
653 }
654
655 #[test]
656 fn parse_json_contract_with_custom_trust() {
657 let json = r#"{
658 "contracts": {
659 "deploy": {
660 "allowed_secrets": ["DB_PASSWORD", "DB_PASSWORD"],
661 "required_secrets": ["DB_PASSWORD"],
662 "trust_level": "custom",
663 "inherit": "clean",
664 "deny_dangerous_env": true,
665 "redact_output": true
666 }
667 }
668}"#;
669 let dir = tempdir().unwrap();
670 let path = dir.path().join(".tsafe.json");
671 std::fs::write(&path, json).unwrap();
672
673 let deploy = load_contract(&path, "deploy").unwrap();
674 assert_eq!(deploy.allowed_secrets, vec!["DB_PASSWORD"]);
675 assert_eq!(
676 deploy.trust,
677 AuthorityTrust::Custom(CustomAuthorityTrust {
678 inherit: AuthorityInheritMode::Clean,
679 deny_dangerous_env: true,
680 redact_output: true,
681 })
682 );
683 }
684
685 #[test]
686 fn missing_trust_level_is_rejected() {
687 let yaml = r#"
688contracts:
689 deploy:
690 allowed_secrets: [DB_PASSWORD]
691"#;
692 let dir = tempdir().unwrap();
693 let path = dir.path().join(".tsafe.yml");
694 std::fs::write(&path, yaml).unwrap();
695
696 let err = load_contracts(&path).unwrap_err();
697 assert!(matches!(
698 err,
699 SafeError::InvalidVault { ref reason } if reason.contains("trust_level is required")
700 ));
701 }
702
703 #[test]
704 fn standard_or_hardened_reject_custom_overrides() {
705 let yaml = r#"
706contracts:
707 deploy:
708 allowed_secrets: [DB_PASSWORD]
709 trust_level: hardened
710 inherit: clean
711"#;
712 let dir = tempdir().unwrap();
713 let path = dir.path().join(".tsafe.yml");
714 std::fs::write(&path, yaml).unwrap();
715
716 let err = load_contracts(&path).unwrap_err();
717 assert!(matches!(
718 err,
719 SafeError::InvalidVault { ref reason }
720 if reason.contains("only valid with trust_level: custom")
721 ));
722 }
723
724 #[test]
725 fn required_secrets_must_be_allowed() {
726 let yaml = r#"
727contracts:
728 deploy:
729 allowed_secrets: [API_KEY]
730 required_secrets: [DB_PASSWORD]
731 trust_level: standard
732"#;
733 let dir = tempdir().unwrap();
734 let path = dir.path().join(".tsafe.yml");
735 std::fs::write(&path, yaml).unwrap();
736
737 let err = load_contracts(&path).unwrap_err();
738 assert!(matches!(
739 err,
740 SafeError::InvalidVault { ref reason }
741 if reason.contains("must also appear in allowed_secrets")
742 ));
743 }
744
745 #[test]
746 fn omitted_allowed_secrets_means_unrestricted_secret_policy() {
747 let yaml = r#"
748contracts:
749 diagnostic:
750 required_secrets: [APP_KEY]
751 trust_level: standard
752"#;
753 let dir = tempdir().unwrap();
754 let path = dir.path().join(".tsafe.yml");
755 std::fs::write(&path, yaml).unwrap();
756
757 let contract = load_contract(&path, "diagnostic").unwrap();
758 assert!(contract.allow_all_secrets);
759 assert!(contract.allowed_secrets.is_empty());
760 assert_eq!(contract.required_secrets, vec!["APP_KEY"]);
761 }
762
763 #[test]
764 fn explicit_empty_allowed_secrets_means_no_secret_allowlist() {
765 let yaml = r#"
766contracts:
767 diagnostic:
768 allowed_secrets: []
769 trust_level: hardened
770"#;
771 let dir = tempdir().unwrap();
772 let path = dir.path().join(".tsafe.yml");
773 std::fs::write(&path, yaml).unwrap();
774
775 let contract = load_contract(&path, "diagnostic").unwrap();
776 assert!(!contract.allow_all_secrets);
777 assert!(contract.allowed_secrets.is_empty());
778 }
779
780 #[test]
781 fn zero_secret_diagnostic_contract_is_identified_conservatively() {
782 let yaml = r#"
783contracts:
784 cordance_diagnostic:
785 allowed_secrets: []
786 required_secrets: []
787 trust_level: hardened
788"#;
789 let dir = tempdir().unwrap();
790 let path = dir.path().join(".tsafe.yml");
791 std::fs::write(&path, yaml).unwrap();
792
793 let contract = load_contract(&path, "cordance_diagnostic").unwrap();
794 assert!(contract.requires_zero_secrets());
795 assert!(contract.injects_zero_secrets());
796 assert!(contract.is_no_secret_diagnostic_contract());
797 }
798
799 #[test]
800 fn omitted_allowed_secrets_is_not_deliberate_zero_secret_diagnostic_shape() {
801 let yaml = r#"
802contracts:
803 missing_required_shape:
804 trust_level: hardened
805"#;
806 let dir = tempdir().unwrap();
807 let path = dir.path().join(".tsafe.yml");
808 std::fs::write(&path, yaml).unwrap();
809
810 let contract = load_contract(&path, "missing_required_shape").unwrap();
811 assert!(contract.allow_all_secrets);
812 assert!(contract.requires_zero_secrets());
813 assert!(!contract.injects_zero_secrets());
814 assert!(!contract.is_no_secret_diagnostic_contract());
815 }
816
817 #[test]
818 fn evaluate_target_returns_explicit_decisions() {
819 let contract = AuthorityContract {
820 name: "deploy".into(),
821 profile: None,
822 namespace: None,
823 access_profile: RbacProfile::ReadWrite,
824 allow_all_secrets: false,
825 allowed_secrets: vec!["DB_PASSWORD".into()],
826 required_secrets: vec!["DB_PASSWORD".into()],
827 allowed_targets: vec!["terraform".into(), "/usr/bin/tofu".into()],
828 trust: AuthorityTrust::Hardened,
829 network: AuthorityNetworkPolicy::Inherit,
830 };
831
832 assert_eq!(
833 contract.evaluate_target(Some("terraform")),
834 AuthorityTargetEvaluation {
835 decision: AuthorityTargetDecision::AllowedExact,
836 matched_allowlist_entry: Some("terraform".into()),
837 }
838 );
839 assert_eq!(
840 contract.evaluate_target(Some("/usr/local/bin/terraform")),
841 AuthorityTargetEvaluation {
842 decision: AuthorityTargetDecision::AllowedBasename,
843 matched_allowlist_entry: Some("terraform".into()),
844 }
845 );
846 assert_eq!(
847 contract.evaluate_target(Some("/usr/bin/tofu")),
848 AuthorityTargetEvaluation {
849 decision: AuthorityTargetDecision::AllowedExact,
850 matched_allowlist_entry: Some("/usr/bin/tofu".into()),
851 }
852 );
853 assert_eq!(
854 contract.evaluate_target(Some("bash")),
855 AuthorityTargetEvaluation {
856 decision: AuthorityTargetDecision::Denied,
857 matched_allowlist_entry: None,
858 }
859 );
860 assert_eq!(
861 contract.evaluate_target(None),
862 AuthorityTargetEvaluation {
863 decision: AuthorityTargetDecision::MissingTarget,
864 matched_allowlist_entry: None,
865 }
866 );
867 assert!(contract.allows_target("terraform"));
868 assert!(!contract.allows_target("bash"));
869 }
870
871 #[test]
872 fn empty_allowed_targets_are_invalid_for_bound_firewall_by_default() {
873 let contract = AuthorityContract {
874 name: "deploy".into(),
875 profile: None,
876 namespace: None,
877 access_profile: RbacProfile::ReadWrite,
878 allow_all_secrets: false,
879 allowed_secrets: vec!["DB_PASSWORD".into()],
880 required_secrets: vec!["DB_PASSWORD".into()],
881 allowed_targets: Vec::new(),
882 trust: AuthorityTrust::Hardened,
883 network: AuthorityNetworkPolicy::Inherit,
884 };
885
886 assert_eq!(
887 validate_bound_firewall_target_policy(&contract),
888 Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
889 );
890 }
891
892 #[test]
893 fn empty_allowed_targets_report_blank_scope_for_bound_firewall() {
894 let contract = AuthorityContract {
895 name: "deploy".into(),
896 profile: None,
897 namespace: None,
898 access_profile: RbacProfile::ReadWrite,
899 allow_all_secrets: false,
900 allowed_secrets: vec!["DB_PASSWORD".into()],
901 required_secrets: vec!["DB_PASSWORD".into()],
902 allowed_targets: Vec::new(),
903 trust: AuthorityTrust::Hardened,
904 network: AuthorityNetworkPolicy::Inherit,
905 };
906
907 let err = validate_bound_firewall_target_policy(&contract).unwrap_err();
908
909 assert_eq!(
910 err.deny_reason(),
911 crate::deny_reason::DenyReason::BlankScope
912 );
913 }
914
915 #[test]
916 fn empty_allowed_targets_are_rejected_even_for_no_secret_diagnostic_contracts() {
917 let diagnostic = AuthorityContract {
918 name: "cordance_diagnostic".into(),
919 profile: None,
920 namespace: None,
921 access_profile: RbacProfile::ReadOnly,
922 allow_all_secrets: false,
923 allowed_secrets: Vec::new(),
924 required_secrets: Vec::new(),
925 allowed_targets: Vec::new(),
926 trust: AuthorityTrust::Hardened,
927 network: AuthorityNetworkPolicy::Inherit,
928 };
929 let ambiguous = AuthorityContract {
930 allow_all_secrets: true,
931 ..diagnostic.clone()
932 };
933
934 assert!(diagnostic.is_no_secret_diagnostic_contract());
935 assert_eq!(
936 validate_bound_firewall_target_policy(&diagnostic),
937 Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
938 );
939 assert!(!ambiguous.is_no_secret_diagnostic_contract());
940 assert_eq!(
941 validate_bound_firewall_target_policy(&ambiguous),
942 Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
943 );
944 }
945
946 #[test]
947 fn zero_secret_diagnostic_contract_still_needs_explicit_bound_target_policy() {
948 let diagnostic = AuthorityContract {
949 name: "cordance_diagnostic".into(),
950 profile: None,
951 namespace: None,
952 access_profile: RbacProfile::ReadOnly,
953 allow_all_secrets: false,
954 allowed_secrets: Vec::new(),
955 required_secrets: Vec::new(),
956 allowed_targets: Vec::new(),
957 trust: AuthorityTrust::Hardened,
958 network: AuthorityNetworkPolicy::Restricted,
959 };
960
961 assert_eq!(
962 validate_bound_firewall_target_policy(&diagnostic),
963 Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
964 );
965 assert_eq!(
966 validate_bound_firewall_network_policy(&diagnostic, true),
967 Ok(())
968 );
969 }
970
971 #[test]
972 fn target_denials_have_deterministic_bound_firewall_reasons() {
973 assert_eq!(
974 AuthorityTargetDecision::Denied.deny_reason(),
975 Some(crate::deny_reason::DenyReason::TargetNotAllowed)
976 );
977 assert_eq!(
978 AuthorityTargetDecision::MissingTarget.deny_reason(),
979 Some(crate::deny_reason::DenyReason::TargetMissing)
980 );
981 assert_eq!(AuthorityTargetDecision::Unconstrained.deny_reason(), None);
982 assert_eq!(AuthorityTargetDecision::AllowedExact.deny_reason(), None);
983 assert_eq!(AuthorityTargetDecision::AllowedBasename.deny_reason(), None);
984 }
985
986 #[test]
987 fn filesystem_bound_firewall_denials_keep_stable_reasons() {
988 assert_eq!(
989 crate::deny_reason::DenyReason::PathEscape.code(),
990 "PATH_ESCAPE"
991 );
992 assert_eq!(
993 crate::deny_reason::DenyReason::BadWorkdir.code(),
994 "BAD_WORKDIR"
995 );
996 assert_eq!(
997 crate::deny_reason::DenyReason::PathEscape.message(),
998 "path escapes the authorized boundary"
999 );
1000 assert_eq!(
1001 crate::deny_reason::DenyReason::BadWorkdir.message(),
1002 "workdir is missing, invalid, unsafe, or not canonicalizable"
1003 );
1004 }
1005
1006 #[test]
1007 fn restricted_network_policy_reports_unenforced_when_host_cannot_enforce() {
1008 let restricted = AuthorityContract {
1009 name: "deploy".into(),
1010 profile: None,
1011 namespace: None,
1012 access_profile: RbacProfile::ReadWrite,
1013 allow_all_secrets: false,
1014 allowed_secrets: vec!["DB_PASSWORD".into()],
1015 required_secrets: vec!["DB_PASSWORD".into()],
1016 allowed_targets: vec!["terraform".into()],
1017 trust: AuthorityTrust::Hardened,
1018 network: AuthorityNetworkPolicy::Restricted,
1019 };
1020 let inherited = AuthorityContract {
1021 network: AuthorityNetworkPolicy::Inherit,
1022 ..restricted.clone()
1023 };
1024
1025 assert_eq!(
1026 validate_bound_firewall_network_policy(&restricted, false),
1027 Err(crate::deny_reason::DenyReason::NetworkUnenforced)
1028 );
1029 assert_eq!(
1030 validate_bound_firewall_network_policy(&restricted, true),
1031 Ok(())
1032 );
1033 assert_eq!(
1034 validate_bound_firewall_network_policy(&inherited, false),
1035 Ok(())
1036 );
1037 }
1038
1039 #[test]
1040 fn evaluate_target_is_unconstrained_without_allowlist() {
1041 let contract = AuthorityContract {
1042 name: "deploy".into(),
1043 profile: None,
1044 namespace: None,
1045 access_profile: RbacProfile::ReadWrite,
1046 allow_all_secrets: false,
1047 allowed_secrets: vec!["DB_PASSWORD".into()],
1048 required_secrets: vec!["DB_PASSWORD".into()],
1049 allowed_targets: Vec::new(),
1050 trust: AuthorityTrust::Standard,
1051 network: AuthorityNetworkPolicy::Inherit,
1052 };
1053
1054 assert_eq!(
1055 contract.evaluate_target(None),
1056 AuthorityTargetEvaluation {
1057 decision: AuthorityTargetDecision::Unconstrained,
1058 matched_allowlist_entry: None,
1059 }
1060 );
1061 }
1062
1063 #[test]
1064 fn find_contracts_manifest_uses_repo_search_rules() {
1065 let dir = tempdir().unwrap();
1066 let nested = dir.path().join("a/b/c");
1067 std::fs::create_dir_all(&nested).unwrap();
1068 let manifest = dir.path().join(".tsafe.yml");
1069 std::fs::write(&manifest, "contracts: {}").unwrap();
1070
1071 assert_eq!(find_contracts_manifest(&nested), Some(manifest));
1072 }
1073
1074 #[test]
1075 fn contracts_default_to_read_write_access_profile() {
1076 let yaml = r#"
1077contracts:
1078 deploy:
1079 allowed_secrets: [DB_PASSWORD]
1080 trust_level: standard
1081"#;
1082 let dir = tempdir().unwrap();
1083 let path = dir.path().join(".tsafe.yml");
1084 std::fs::write(&path, yaml).unwrap();
1085
1086 let deploy = load_contract(&path, "deploy").unwrap();
1087 assert_eq!(deploy.access_profile, RbacProfile::ReadWrite);
1088 assert_eq!(
1089 deploy.resolved_exec_policy().access_profile,
1090 RbacProfile::ReadWrite
1091 );
1092 }
1093}