use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::baseline_contracts::{
ci_deploy_contract, ops_emergency_contract, read_only_contract, AccessLevel,
};
use crate::errors::{SafeError, SafeResult};
use crate::namespace_bulk::validate_namespace_segment;
use crate::profile::validate_profile_name;
use crate::pullconfig::find_config;
use crate::rbac::RbacProfile;
use crate::vault::validate_secret_key;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityContract {
pub name: String,
pub profile: Option<String>,
pub namespace: Option<String>,
pub access_profile: RbacProfile,
pub allowed_secrets: Vec<String>,
pub required_secrets: Vec<String>,
pub allowed_targets: Vec<String>,
pub trust: AuthorityTrust,
pub network: AuthorityNetworkPolicy,
}
impl AuthorityContract {
pub fn resolved_exec_policy(&self) -> ResolvedAuthorityPolicy {
match &self.trust {
AuthorityTrust::Standard => ResolvedAuthorityPolicy {
trust_level: AuthorityTrustLevel::Standard,
access_profile: self.access_profile,
inherit: AuthorityInheritMode::Full,
deny_dangerous_env: false,
redact_output: false,
},
AuthorityTrust::Hardened => ResolvedAuthorityPolicy {
trust_level: AuthorityTrustLevel::Hardened,
access_profile: self.access_profile,
inherit: AuthorityInheritMode::Minimal,
deny_dangerous_env: true,
redact_output: true,
},
AuthorityTrust::Custom(custom) => ResolvedAuthorityPolicy {
trust_level: AuthorityTrustLevel::Custom,
access_profile: self.access_profile,
inherit: custom.inherit,
deny_dangerous_env: custom.deny_dangerous_env,
redact_output: custom.redact_output,
},
}
}
pub fn allows_target(&self, command: &str) -> bool {
self.evaluate_target(Some(command)).decision.is_allowed()
}
pub fn evaluate_target(&self, command: Option<&str>) -> AuthorityTargetEvaluation {
if self.allowed_targets.is_empty() {
return AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::Unconstrained,
matched_allowlist_entry: None,
};
}
let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else {
return AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::MissingTarget,
matched_allowlist_entry: None,
};
};
if let Some(matched) = self
.allowed_targets
.iter()
.find(|allowed| allowed == &command)
{
return AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::AllowedExact,
matched_allowlist_entry: Some(matched.clone()),
};
}
let basename = Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(command);
if let Some(matched) = self
.allowed_targets
.iter()
.find(|allowed| allowed.as_str() == basename)
{
return AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::AllowedBasename,
matched_allowlist_entry: Some(matched.clone()),
};
}
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::Denied,
matched_allowlist_entry: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityTrustLevel {
Standard,
Hardened,
Custom,
}
impl AuthorityTrustLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Hardened => "hardened",
Self::Custom => "custom",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorityTrust {
Standard,
Hardened,
Custom(CustomAuthorityTrust),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomAuthorityTrust {
pub inherit: AuthorityInheritMode,
pub deny_dangerous_env: bool,
pub redact_output: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityInheritMode {
Full,
Minimal,
Clean,
}
impl AuthorityInheritMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Full => "full",
Self::Minimal => "minimal",
Self::Clean => "clean",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityNetworkPolicy {
#[default]
Inherit,
Restricted,
}
impl AuthorityNetworkPolicy {
pub fn as_str(self) -> &'static str {
match self {
Self::Inherit => "inherit",
Self::Restricted => "restricted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedAuthorityPolicy {
pub trust_level: AuthorityTrustLevel,
pub access_profile: RbacProfile,
pub inherit: AuthorityInheritMode,
pub deny_dangerous_env: bool,
pub redact_output: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityTargetDecision {
Unconstrained,
AllowedExact,
AllowedBasename,
MissingTarget,
Denied,
}
impl AuthorityTargetDecision {
pub fn is_allowed(self) -> bool {
matches!(
self,
Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityTargetEvaluation {
pub decision: AuthorityTargetDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_allowlist_entry: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawContractsFile {
#[serde(default)]
contracts: BTreeMap<String, RawAuthorityContract>,
}
#[derive(Debug, Default, Deserialize)]
struct RawAuthorityContract {
#[serde(default)]
template: Option<String>,
#[serde(default)]
profile: Option<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
access_profile: Option<RbacProfile>,
#[serde(default)]
allowed_secrets: Vec<String>,
#[serde(default)]
required_secrets: Vec<String>,
#[serde(default)]
allowed_targets: Vec<String>,
#[serde(default)]
trust_level: Option<AuthorityTrustLevel>,
#[serde(default)]
inherit: Option<AuthorityInheritMode>,
#[serde(default)]
deny_dangerous_env: Option<bool>,
#[serde(default)]
redact_output: Option<bool>,
#[serde(default)]
network: AuthorityNetworkPolicy,
}
pub fn find_contracts_manifest(start: &Path) -> Option<PathBuf> {
find_config(start)
}
pub fn load_contracts(path: &Path) -> SafeResult<BTreeMap<String, AuthorityContract>> {
let content = std::fs::read_to_string(path)?;
let raw: RawContractsFile = if is_json(path) {
serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
reason: format!("invalid authority contract JSON: {e}"),
})?
} else {
serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
reason: format!("invalid authority contract YAML: {e}"),
})?
};
raw.contracts
.into_iter()
.map(|(name, raw)| Ok((name.clone(), validate_contract(name, raw)?)))
.collect()
}
pub fn load_contract(path: &Path, name: &str) -> SafeResult<AuthorityContract> {
let contracts = load_contracts(path)?;
contracts
.get(name)
.cloned()
.ok_or_else(|| SafeError::InvalidVault {
reason: format!(
"authority contract '{name}' not found in {}",
path.display()
),
})
}
fn apply_template_defaults(contract_name: &str, raw: &mut RawAuthorityContract) -> SafeResult<()> {
let tmpl_name = match raw.template.as_deref() {
Some(n) => n,
None => return Ok(()),
};
let baseline = match tmpl_name {
"read_only" => read_only_contract(),
"ci_deploy" => ci_deploy_contract(vec!["terraform".to_string(), "ansible".to_string()]),
"ops_emergency" => ops_emergency_contract(),
other => {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{contract_name}': unknown template '{other}' \
(available: read_only, ci_deploy, ops_emergency)"
),
})
}
};
if raw.trust_level.is_none() {
raw.trust_level = Some(match baseline.required_trust_profile.as_str() {
"hardened" => AuthorityTrustLevel::Hardened,
_ => AuthorityTrustLevel::Standard,
});
}
if raw.access_profile.is_none() {
raw.access_profile = Some(match baseline.access_level {
AccessLevel::ReadOnly => RbacProfile::ReadOnly,
AccessLevel::ReadWrite => RbacProfile::ReadWrite,
});
}
if raw.allowed_secrets.is_empty() {
raw.allowed_secrets = baseline.secret_constraints.allowed_secrets;
}
if raw.required_secrets.is_empty() {
raw.required_secrets = baseline.secret_constraints.required_secrets;
}
if raw.allowed_targets.is_empty() {
if let Some(targets) = baseline.target_constraints {
raw.allowed_targets = targets;
}
}
Ok(())
}
fn validate_contract(name: String, mut raw: RawAuthorityContract) -> SafeResult<AuthorityContract> {
validate_contract_name(&name)?;
apply_template_defaults(&name, &mut raw)?;
let trust_level = raw.trust_level.ok_or_else(|| SafeError::InvalidVault {
reason: format!("contract '{name}': trust_level is required (or use template: <name>)"),
})?;
if matches!(
trust_level,
AuthorityTrustLevel::Standard | AuthorityTrustLevel::Hardened
) {
reject_custom_overrides(&name, &raw)?;
}
let profile = raw.profile.map(|profile| profile.trim().to_string());
if let Some(profile) = profile.as_deref() {
validate_profile_name(profile)?;
}
let namespace = raw.namespace.map(|namespace| namespace.trim().to_string());
if let Some(namespace) = namespace.as_deref() {
validate_namespace_segment(namespace)?;
}
let allowed_secrets =
normalize_contract_secret_names(&name, "allowed_secrets", raw.allowed_secrets)?;
let required_secrets =
normalize_contract_secret_names(&name, "required_secrets", raw.required_secrets)?;
for secret in &required_secrets {
if !allowed_secrets.iter().any(|allowed| allowed == secret) {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{name}': required secret '{secret}' must also appear in allowed_secrets"
),
});
}
}
let trust = match trust_level {
AuthorityTrustLevel::Standard => AuthorityTrust::Standard,
AuthorityTrustLevel::Hardened => AuthorityTrust::Hardened,
AuthorityTrustLevel::Custom => AuthorityTrust::Custom(CustomAuthorityTrust {
inherit: raw.inherit.unwrap_or(AuthorityInheritMode::Full),
deny_dangerous_env: raw.deny_dangerous_env.unwrap_or(false),
redact_output: raw.redact_output.unwrap_or(false),
}),
};
let allowed_targets = normalize_allowed_targets(&name, raw.allowed_targets)?;
Ok(AuthorityContract {
name,
profile,
namespace,
access_profile: raw.access_profile.unwrap_or_default(),
allowed_secrets,
required_secrets,
allowed_targets,
trust,
network: raw.network,
})
}
fn reject_custom_overrides(name: &str, raw: &RawAuthorityContract) -> SafeResult<()> {
if raw.inherit.is_some() || raw.deny_dangerous_env.is_some() || raw.redact_output.is_some() {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{name}': inherit / deny_dangerous_env / redact_output are only valid with trust_level: custom"
),
});
}
Ok(())
}
fn normalize_contract_secret_names(
contract_name: &str,
field: &str,
names: Vec<String>,
) -> SafeResult<Vec<String>> {
let mut out = Vec::new();
for name in names {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(SafeError::InvalidVault {
reason: format!("contract '{contract_name}': {field} contains an empty name"),
});
}
if trimmed.contains('/') {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{contract_name}': {field} entry '{trimmed}' must be a post-namespace secret/env name, not a namespaced vault key"
),
});
}
validate_secret_key(trimmed)?;
if !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_string());
}
}
out.sort();
Ok(out)
}
fn normalize_allowed_targets(contract_name: &str, targets: Vec<String>) -> SafeResult<Vec<String>> {
let mut out = Vec::new();
for target in targets {
let trimmed = target.trim();
if trimmed.is_empty() {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{contract_name}': allowed_targets contains an empty target"
),
});
}
if trimmed.chars().any(char::is_control) {
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{contract_name}': target '{trimmed}' contains control characters"
),
});
}
if !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_string());
}
}
out.sort();
Ok(out)
}
fn validate_contract_name(name: &str) -> SafeResult<()> {
if name.is_empty() {
return Err(SafeError::InvalidVault {
reason: "contract name cannot be empty".into(),
});
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(SafeError::InvalidVault {
reason: format!(
"contract '{name}': only ASCII letters, digits, '-', '_' and '.' are allowed"
),
});
}
Ok(())
}
fn is_json(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e == "json")
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_yaml_contracts_ignores_other_manifest_sections() {
let yaml = r#"
pulls:
- source: akv
vault_url: https://example.vault.azure.net
contracts:
deploy:
profile: work
namespace: infra
allowed_secrets:
- DB_PASSWORD
- API_KEY
required_secrets:
- DB_PASSWORD
allowed_targets:
- terraform
- /usr/bin/tofu
access_profile: read_only
trust_level: hardened
network: restricted
"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.yml");
std::fs::write(&path, yaml).unwrap();
let contracts = load_contracts(&path).unwrap();
let deploy = contracts.get("deploy").unwrap();
assert_eq!(deploy.profile.as_deref(), Some("work"));
assert_eq!(deploy.namespace.as_deref(), Some("infra"));
assert_eq!(deploy.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
assert_eq!(deploy.required_secrets, vec!["DB_PASSWORD"]);
assert_eq!(deploy.allowed_targets, vec!["/usr/bin/tofu", "terraform"]);
assert_eq!(deploy.access_profile, RbacProfile::ReadOnly);
assert_eq!(deploy.network, AuthorityNetworkPolicy::Restricted);
assert_eq!(
deploy.resolved_exec_policy().access_profile,
RbacProfile::ReadOnly
);
assert_eq!(
deploy.resolved_exec_policy().inherit,
AuthorityInheritMode::Minimal
);
assert!(deploy.resolved_exec_policy().deny_dangerous_env);
assert!(deploy.resolved_exec_policy().redact_output);
}
#[test]
fn parse_json_contract_with_custom_trust() {
let json = r#"{
"contracts": {
"deploy": {
"allowed_secrets": ["DB_PASSWORD", "DB_PASSWORD"],
"required_secrets": ["DB_PASSWORD"],
"trust_level": "custom",
"inherit": "clean",
"deny_dangerous_env": true,
"redact_output": true
}
}
}"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.json");
std::fs::write(&path, json).unwrap();
let deploy = load_contract(&path, "deploy").unwrap();
assert_eq!(deploy.allowed_secrets, vec!["DB_PASSWORD"]);
assert_eq!(
deploy.trust,
AuthorityTrust::Custom(CustomAuthorityTrust {
inherit: AuthorityInheritMode::Clean,
deny_dangerous_env: true,
redact_output: true,
})
);
}
#[test]
fn missing_trust_level_is_rejected() {
let yaml = r#"
contracts:
deploy:
allowed_secrets: [DB_PASSWORD]
"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.yml");
std::fs::write(&path, yaml).unwrap();
let err = load_contracts(&path).unwrap_err();
assert!(matches!(
err,
SafeError::InvalidVault { ref reason } if reason.contains("trust_level is required")
));
}
#[test]
fn standard_or_hardened_reject_custom_overrides() {
let yaml = r#"
contracts:
deploy:
allowed_secrets: [DB_PASSWORD]
trust_level: hardened
inherit: clean
"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.yml");
std::fs::write(&path, yaml).unwrap();
let err = load_contracts(&path).unwrap_err();
assert!(matches!(
err,
SafeError::InvalidVault { ref reason }
if reason.contains("only valid with trust_level: custom")
));
}
#[test]
fn required_secrets_must_be_allowed() {
let yaml = r#"
contracts:
deploy:
allowed_secrets: [API_KEY]
required_secrets: [DB_PASSWORD]
trust_level: standard
"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.yml");
std::fs::write(&path, yaml).unwrap();
let err = load_contracts(&path).unwrap_err();
assert!(matches!(
err,
SafeError::InvalidVault { ref reason }
if reason.contains("must also appear in allowed_secrets")
));
}
#[test]
fn evaluate_target_returns_explicit_decisions() {
let contract = AuthorityContract {
name: "deploy".into(),
profile: None,
namespace: None,
access_profile: RbacProfile::ReadWrite,
allowed_secrets: vec!["DB_PASSWORD".into()],
required_secrets: vec!["DB_PASSWORD".into()],
allowed_targets: vec!["terraform".into(), "/usr/bin/tofu".into()],
trust: AuthorityTrust::Hardened,
network: AuthorityNetworkPolicy::Inherit,
};
assert_eq!(
contract.evaluate_target(Some("terraform")),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::AllowedExact,
matched_allowlist_entry: Some("terraform".into()),
}
);
assert_eq!(
contract.evaluate_target(Some("/usr/local/bin/terraform")),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::AllowedBasename,
matched_allowlist_entry: Some("terraform".into()),
}
);
assert_eq!(
contract.evaluate_target(Some("/usr/bin/tofu")),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::AllowedExact,
matched_allowlist_entry: Some("/usr/bin/tofu".into()),
}
);
assert_eq!(
contract.evaluate_target(Some("bash")),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::Denied,
matched_allowlist_entry: None,
}
);
assert_eq!(
contract.evaluate_target(None),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::MissingTarget,
matched_allowlist_entry: None,
}
);
assert!(contract.allows_target("terraform"));
assert!(!contract.allows_target("bash"));
}
#[test]
fn evaluate_target_is_unconstrained_without_allowlist() {
let contract = AuthorityContract {
name: "deploy".into(),
profile: None,
namespace: None,
access_profile: RbacProfile::ReadWrite,
allowed_secrets: vec!["DB_PASSWORD".into()],
required_secrets: vec!["DB_PASSWORD".into()],
allowed_targets: Vec::new(),
trust: AuthorityTrust::Standard,
network: AuthorityNetworkPolicy::Inherit,
};
assert_eq!(
contract.evaluate_target(None),
AuthorityTargetEvaluation {
decision: AuthorityTargetDecision::Unconstrained,
matched_allowlist_entry: None,
}
);
}
#[test]
fn find_contracts_manifest_uses_repo_search_rules() {
let dir = tempdir().unwrap();
let nested = dir.path().join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
let manifest = dir.path().join(".tsafe.yml");
std::fs::write(&manifest, "contracts: {}").unwrap();
assert_eq!(find_contracts_manifest(&nested), Some(manifest));
}
#[test]
fn contracts_default_to_read_write_access_profile() {
let yaml = r#"
contracts:
deploy:
allowed_secrets: [DB_PASSWORD]
trust_level: standard
"#;
let dir = tempdir().unwrap();
let path = dir.path().join(".tsafe.yml");
std::fs::write(&path, yaml).unwrap();
let deploy = load_contract(&path, "deploy").unwrap();
assert_eq!(deploy.access_profile, RbacProfile::ReadWrite);
assert_eq!(
deploy.resolved_exec_policy().access_profile,
RbacProfile::ReadWrite
);
}
}