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::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#[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 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 pub fn allows_target(&self, command: &str) -> bool {
80 self.evaluate_target(Some(command)).decision.is_allowed()
81 }
82
83 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum AuthorityTrust {
158 Standard,
159 Hardened,
160 Custom(CustomAuthorityTrust),
161}
162
163#[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#[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#[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#[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#[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#[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 #[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
284pub fn find_contracts_manifest(start: &Path) -> Option<PathBuf> {
286 find_config(start)
287}
288
289pub 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
311pub 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 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}