tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! `tsafe validate` — cross-check authority contracts against a CellOS policy pack.
//!
//! Authority contracts declare `allowed_secrets`; CellOS policy packs declare
//! `allowedSecretRefs`.  Mismatches cause runtime surprises: a cell is denied a
//! secret tsafe would grant, or tsafe allows a secret the policy never intended.
//! This command surfaces those gaps at config-review time, not at runtime.
//!
//! Use `--json` for machine-readable output; exit codes are preserved (0=pass,
//! non-zero=mismatch).

use std::collections::BTreeSet;
use std::path::Path;

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{
    contracts::{find_contracts_manifest, load_contracts, AuthorityTrustLevel},
    rbac::RbacProfile,
};

/// Parsed representation of the CellOS policy pack (subset we care about).
#[derive(Debug, serde::Deserialize)]
struct CellosPolicyPack {
    #[serde(rename = "allowedSecretRefs")]
    allowed_secret_refs: Vec<String>,
    #[serde(rename = "requiredSecretRefs")]
    required_secret_refs: Option<Vec<String>>,
    #[serde(rename = "allowedTargets")]
    allowed_targets: Option<Vec<String>>,
    #[serde(rename = "accessProfile")]
    access_profile: Option<RbacProfile>,
    #[serde(rename = "trustLevel")]
    trust_level: Option<AuthorityTrustLevel>,
}

#[derive(Debug)]
struct PolicyExpectation {
    allowed_secret_refs: BTreeSet<String>,
    required_secret_refs: Option<BTreeSet<String>>,
    allowed_targets: Option<BTreeSet<String>>,
    access_profile: Option<RbacProfile>,
    trust_level: Option<AuthorityTrustLevel>,
}

#[derive(Debug)]
struct ContractPosture {
    name: String,
    allowed_secret_refs: BTreeSet<String>,
    required_secret_refs: BTreeSet<String>,
    allowed_targets: BTreeSet<String>,
    access_profile: RbacProfile,
    trust_level: AuthorityTrustLevel,
}

impl ContractPosture {
    fn mismatch_fields(&self, policy: &PolicyExpectation) -> Vec<&'static str> {
        let mut mismatches = Vec::new();
        if self.allowed_secret_refs != policy.allowed_secret_refs {
            mismatches.push("allowedSecretRefs");
        }
        if policy
            .required_secret_refs
            .as_ref()
            .is_some_and(|required| &self.required_secret_refs != required)
        {
            mismatches.push("requiredSecretRefs");
        }
        if policy
            .allowed_targets
            .as_ref()
            .is_some_and(|targets| &self.allowed_targets != targets)
        {
            mismatches.push("allowedTargets");
        }
        if policy
            .access_profile
            .is_some_and(|access| self.access_profile != access)
        {
            mismatches.push("accessProfile");
        }
        if policy
            .trust_level
            .is_some_and(|trust| self.trust_level != trust)
        {
            mismatches.push("trustLevel");
        }
        mismatches
    }

    fn mismatch_descriptions(&self, policy: &PolicyExpectation) -> Vec<String> {
        let mut mismatches = Vec::new();
        if self.allowed_secret_refs != policy.allowed_secret_refs {
            mismatches.push(format!(
                "allowedSecretRefs contract=[{}] policy=[{}]",
                join_sorted(&self.allowed_secret_refs),
                join_sorted(&policy.allowed_secret_refs)
            ));
        }
        if let Some(required) = policy.required_secret_refs.as_ref() {
            if &self.required_secret_refs != required {
                mismatches.push(format!(
                    "requiredSecretRefs contract=[{}] policy=[{}]",
                    join_sorted(&self.required_secret_refs),
                    join_sorted(required)
                ));
            }
        }
        if let Some(targets) = policy.allowed_targets.as_ref() {
            if &self.allowed_targets != targets {
                mismatches.push(format!(
                    "allowedTargets contract=[{}] policy=[{}]",
                    join_sorted(&self.allowed_targets),
                    join_sorted(targets)
                ));
            }
        }
        if let Some(access_profile) = policy.access_profile {
            if self.access_profile != access_profile {
                mismatches.push(format!(
                    "accessProfile contract={} policy={}",
                    self.access_profile.as_str(),
                    access_profile.as_str()
                ));
            }
        }
        if let Some(trust_level) = policy.trust_level {
            if self.trust_level != trust_level {
                mismatches.push(format!(
                    "trustLevel contract={} policy={}",
                    self.trust_level.as_str(),
                    trust_level.as_str()
                ));
            }
        }
        mismatches
    }
}

fn join_sorted(values: &BTreeSet<String>) -> String {
    if values.is_empty() {
        "(none)".to_string()
    } else {
        values.iter().cloned().collect::<Vec<_>>().join(", ")
    }
}

pub(crate) fn cmd_validate(cellos_policy_path: &Path, json_out: bool) -> Result<()> {
    // ── Load the contracts manifest ───────────────────────────────────────────
    let cwd = std::env::current_dir().context("could not read current directory")?;
    let manifest_path = find_contracts_manifest(&cwd).ok_or_else(|| {
        anyhow::anyhow!(
            "no .tsafe.yml or .tsafe.json found in '{}' or any parent directory",
            cwd.display()
        )
    })?;

    let contracts = load_contracts(&manifest_path).context("failed to load authority contracts")?;

    // ── Collect all allowed_secrets across all contracts ──────────────────────
    let mut contract_secrets: BTreeSet<String> = BTreeSet::new();
    let mut contract_required_secrets: BTreeSet<String> = BTreeSet::new();
    let mut contract_targets: BTreeSet<String> = BTreeSet::new();
    let contract_postures: Vec<ContractPosture> = contracts
        .values()
        .map(|contract| {
            for s in &contract.allowed_secrets {
                contract_secrets.insert(s.clone());
            }
            for s in &contract.required_secrets {
                contract_required_secrets.insert(s.clone());
            }
            for target in &contract.allowed_targets {
                contract_targets.insert(target.clone());
            }
            let resolved = contract.resolved_exec_policy();
            ContractPosture {
                name: contract.name.clone(),
                allowed_secret_refs: contract.allowed_secrets.iter().cloned().collect(),
                required_secret_refs: contract.required_secrets.iter().cloned().collect(),
                allowed_targets: contract.allowed_targets.iter().cloned().collect(),
                access_profile: resolved.access_profile,
                trust_level: resolved.trust_level,
            }
        })
        .collect();

    // ── Parse the CellOS policy pack ─────────────────────────────────────────
    let policy_json = std::fs::read_to_string(cellos_policy_path).with_context(|| {
        format!(
            "could not read CellOS policy pack '{}'",
            cellos_policy_path.display()
        )
    })?;
    let policy: CellosPolicyPack = serde_json::from_str(&policy_json).with_context(|| {
        format!(
            "invalid CellOS policy JSON in '{}'",
            cellos_policy_path.display()
        )
    })?;
    let policy = PolicyExpectation {
        allowed_secret_refs: policy.allowed_secret_refs.into_iter().collect(),
        required_secret_refs: policy
            .required_secret_refs
            .map(|names| names.into_iter().collect()),
        allowed_targets: policy
            .allowed_targets
            .map(|names| names.into_iter().collect()),
        access_profile: policy.access_profile,
        trust_level: policy.trust_level,
    };

    let mut matching_contracts = Vec::new();
    let mut mismatch_rows: Vec<(String, Vec<String>)> = Vec::new();
    let mut conflicts: BTreeSet<&'static str> = BTreeSet::new();
    for contract in &contract_postures {
        let mismatch_fields = contract.mismatch_fields(&policy);
        if mismatch_fields.is_empty() {
            matching_contracts.push(contract.name.clone());
            continue;
        }
        for field in mismatch_fields {
            conflicts.insert(field);
        }
        mismatch_rows.push((
            contract.name.clone(),
            contract.mismatch_descriptions(&policy),
        ));
    }

    let passed = !matching_contracts.is_empty();
    let conflict_fields: Vec<&'static str> = conflicts.into_iter().collect();

    if json_out {
        let mismatches_json: Vec<serde_json::Value> = mismatch_rows
            .iter()
            .map(|(name, descs)| {
                serde_json::json!({
                    "contract": name,
                    "mismatches": descs,
                })
            })
            .collect();
        let out = serde_json::json!({
            "manifest": manifest_path.display().to_string(),
            "policy_file": cellos_policy_path.display().to_string(),
            "contract_count": contracts.len(),
            "passed": passed,
            "matching_contracts": matching_contracts,
            "conflict_fields": conflict_fields,
            "contract_mismatches": mismatches_json,
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
        if !passed {
            anyhow::bail!(
                "{} conflict(s) found — align .tsafe.yml with CellOS {}",
                conflict_fields.len(),
                conflict_fields.join(", ")
            );
        }
        return Ok(());
    }

    println!(
        "{} Validating '{}' against '{}'",
        "i".blue(),
        manifest_path.display(),
        cellos_policy_path.display()
    );
    println!(
        "  Contracts: {}  |  Contract secrets: {}  |  Policy allowedSecretRefs: {}",
        contracts.len(),
        contract_secrets.len(),
        policy.allowed_secret_refs.len()
    );
    println!(
        "  Contract required secrets: {}  |  Contract allowed_targets: {}",
        contract_required_secrets.len(),
        contract_targets.len()
    );
    println!();
    println!("  Contract posture:");
    for contract in &contract_postures {
        println!(
            "    - {}  access={}  trust={}  required={}  targets={}",
            contract.name.cyan(),
            contract.access_profile.as_str().cyan(),
            contract.trust_level.as_str().cyan(),
            contract.required_secret_refs.len(),
            contract.allowed_targets.len()
        );
    }
    println!();

    if passed {
        println!(
            "{} No conflicts — matching contract(s): {}",
            "".green(),
            matching_contracts.join(", ").cyan()
        );
        return Ok(());
    }

    println!(
        "{} No single contract in '{}' matches the CellOS policy pack exactly.",
        "!".yellow().bold(),
        manifest_path.display()
    );
    for (name, mismatches) in mismatch_rows {
        println!("  {} {}:", "".yellow(), name.cyan());
        for mismatch in mismatches {
            println!("      {}", mismatch.yellow());
        }
    }
    println!();

    anyhow::bail!(
        "{} conflict(s) found — align .tsafe.yml with CellOS {}",
        conflict_fields.len(),
        conflict_fields.join(", ")
    );
}