use serde::{Deserialize, Serialize};
use crate::audit::{AuditExecContext, AuditStatus};
use crate::audit_explain::ExecutionAuthoritySummary;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComplianceNarrative {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted: Option<GrantedAuthority>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<TargetDecisionChain>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denied_stripped: Option<DeniedAndStripped>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantedAuthority {
pub policy_source: String,
pub secret_count: usize,
pub trust_level: String,
pub inherit_mode: String,
pub output_redacted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetDecisionChain {
pub command: String,
pub decision_code: String,
pub decision_explanation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeniedAndStripped {
pub stripped_env_names: Vec<String>,
pub blocked_secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_denied_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_profile_notes: Option<String>,
}
impl ComplianceNarrative {
pub fn from_exec_context(
ctx: &AuditExecContext,
_summary: Option<&ExecutionAuthoritySummary>,
_status: &AuditStatus,
) -> Self {
let granted = ctx.trust_level.as_ref().map(|trust| GrantedAuthority {
policy_source: ctx
.contract_name
.clone()
.unwrap_or_else(|| "explicit flags".to_string()),
secret_count: ctx.injected_secrets.len(),
trust_level: trust.as_str().to_string(),
inherit_mode: ctx
.inherit
.map(|m| format!("{m:?}").to_lowercase())
.unwrap_or_else(|| "unknown".to_string()),
output_redacted: ctx.redact_output.unwrap_or(false),
});
let target = ctx.target.as_ref().map(|cmd| {
let (decision_code, decision_explanation) = match ctx.target_decision {
Some(td) => (format!("{td:?}"), format!("{td:?}")),
None => (
"UNKNOWN".to_string(),
"no target decision recorded".to_string(),
),
};
let denial_reason = ctx
.deny_reason
.map(|r| format!("{}: {}", r.code(), r.message()));
TargetDecisionChain {
command: cmd.clone(),
decision_code,
decision_explanation,
denial_reason,
}
});
let denied_stripped = Some(DeniedAndStripped {
stripped_env_names: ctx.dropped_env_names.clone(),
blocked_secrets: ctx
.allowed_secrets
.iter()
.filter(|s| !ctx.injected_secrets.contains(s))
.cloned()
.collect(),
operation_denied_reason: ctx
.deny_reason
.map(|r| format!("{}: {}", r.code(), r.message())),
access_profile_notes: None,
});
Self {
granted,
target,
denied_stripped,
}
}
pub fn to_plaintext(&self) -> String {
let mut output = String::new();
if let Some(grant) = &self.granted {
output.push_str("granted:\n");
output.push_str(&format!(" policy_source: {}\n", grant.policy_source));
output.push_str(&format!(" secret_count: {}\n", grant.secret_count));
output.push_str(&format!(" trust_level: {}\n", grant.trust_level));
output.push_str(&format!(" inherit_mode: {}\n", grant.inherit_mode));
output.push_str(&format!(" output_redacted: {}\n", grant.output_redacted));
}
if let Some(tgt) = &self.target {
output.push_str("target:\n");
output.push_str(&format!(" command: {}\n", tgt.command));
output.push_str(&format!(" decision: {}\n", tgt.decision_code));
if let Some(reason) = &tgt.denial_reason {
output.push_str(&format!(" denial_reason: {reason}\n"));
}
}
if let Some(ds) = &self.denied_stripped {
output.push_str("denied_stripped:\n");
if !ds.stripped_env_names.is_empty() {
output.push_str(&format!(
" stripped_env: {}\n",
ds.stripped_env_names.join(", ")
));
}
if !ds.blocked_secrets.is_empty() {
output.push_str(&format!(
" blocked_secrets: {}\n",
ds.blocked_secrets.len()
));
}
if let Some(reason) = &ds.operation_denied_reason {
output.push_str(&format!(" denial_reason: {reason}\n"));
}
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compliance_narrative_plaintext_format_is_readable() {
let narrative = ComplianceNarrative {
granted: Some(GrantedAuthority {
policy_source: "deploy".to_string(),
secret_count: 2,
trust_level: "hardened".to_string(),
inherit_mode: "minimal".to_string(),
output_redacted: true,
}),
target: Some(TargetDecisionChain {
command: "terraform".to_string(),
decision_code: "AllowedExact".to_string(),
decision_explanation: "matched allowlist by exact name".to_string(),
denial_reason: None,
}),
denied_stripped: Some(DeniedAndStripped {
stripped_env_names: vec!["HOME".to_string(), "PATH".to_string()],
blocked_secrets: vec![],
operation_denied_reason: None,
access_profile_notes: None,
}),
};
let text = narrative.to_plaintext();
assert!(text.contains("granted:"));
assert!(text.contains("policy_source: deploy"));
assert!(text.contains("target:"));
assert!(text.contains("command: terraform"));
assert!(text.contains("denied_stripped:"));
assert!(text.contains("stripped_env: HOME, PATH"));
}
}