1use serde::{Deserialize, Serialize};
12
13use crate::audit::{AuditExecContext, AuditStatus};
14use crate::audit_explain::ExecutionAuthoritySummary;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ComplianceNarrative {
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub granted: Option<GrantedAuthority>,
22
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub target: Option<TargetDecisionChain>,
26
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub denied_stripped: Option<DeniedAndStripped>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct GrantedAuthority {
35 pub policy_source: String,
37
38 pub secret_count: usize,
40
41 pub trust_level: String,
43
44 pub inherit_mode: String,
46
47 pub output_redacted: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct TargetDecisionChain {
54 pub command: String,
56
57 pub decision_code: String,
59
60 pub decision_explanation: String,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub denial_reason: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct DeniedAndStripped {
71 pub stripped_env_names: Vec<String>,
73
74 pub blocked_secrets: Vec<String>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub operation_denied_reason: Option<String>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub access_profile_notes: Option<String>,
84}
85
86impl ComplianceNarrative {
87 pub fn from_exec_context(
89 ctx: &AuditExecContext,
90 _summary: Option<&ExecutionAuthoritySummary>,
91 _status: &AuditStatus,
92 ) -> Self {
93 let granted = ctx.trust_level.as_ref().map(|trust| GrantedAuthority {
94 policy_source: ctx
95 .contract_name
96 .clone()
97 .unwrap_or_else(|| "explicit flags".to_string()),
98 secret_count: ctx.injected_secrets.len(),
99 trust_level: trust.as_str().to_string(),
100 inherit_mode: ctx
101 .inherit
102 .map(|m| format!("{m:?}").to_lowercase())
103 .unwrap_or_else(|| "unknown".to_string()),
104 output_redacted: ctx.redact_output.unwrap_or(false),
105 });
106
107 let target = ctx.target.as_ref().map(|cmd| {
108 let (decision_code, decision_explanation) = match ctx.target_decision {
109 Some(td) => (format!("{td:?}"), format!("{td:?}")),
110 None => (
111 "UNKNOWN".to_string(),
112 "no target decision recorded".to_string(),
113 ),
114 };
115
116 let denial_reason = ctx
117 .deny_reason
118 .map(|r| format!("{}: {}", r.code(), r.message()));
119
120 TargetDecisionChain {
121 command: cmd.clone(),
122 decision_code,
123 decision_explanation,
124 denial_reason,
125 }
126 });
127
128 let denied_stripped = Some(DeniedAndStripped {
129 stripped_env_names: ctx.dropped_env_names.clone(),
130 blocked_secrets: ctx
131 .allowed_secrets
132 .iter()
133 .filter(|s| !ctx.injected_secrets.contains(s))
134 .cloned()
135 .collect(),
136 operation_denied_reason: ctx
137 .deny_reason
138 .map(|r| format!("{}: {}", r.code(), r.message())),
139 access_profile_notes: None,
140 });
141
142 Self {
143 granted,
144 target,
145 denied_stripped,
146 }
147 }
148
149 pub fn to_plaintext(&self) -> String {
151 let mut output = String::new();
152
153 if let Some(grant) = &self.granted {
155 output.push_str("granted:\n");
156 output.push_str(&format!(" policy_source: {}\n", grant.policy_source));
157 output.push_str(&format!(" secret_count: {}\n", grant.secret_count));
158 output.push_str(&format!(" trust_level: {}\n", grant.trust_level));
159 output.push_str(&format!(" inherit_mode: {}\n", grant.inherit_mode));
160 output.push_str(&format!(" output_redacted: {}\n", grant.output_redacted));
161 }
162
163 if let Some(tgt) = &self.target {
165 output.push_str("target:\n");
166 output.push_str(&format!(" command: {}\n", tgt.command));
167 output.push_str(&format!(" decision: {}\n", tgt.decision_code));
168 if let Some(reason) = &tgt.denial_reason {
169 output.push_str(&format!(" denial_reason: {reason}\n"));
170 }
171 }
172
173 if let Some(ds) = &self.denied_stripped {
175 output.push_str("denied_stripped:\n");
176 if !ds.stripped_env_names.is_empty() {
177 output.push_str(&format!(
178 " stripped_env: {}\n",
179 ds.stripped_env_names.join(", ")
180 ));
181 }
182 if !ds.blocked_secrets.is_empty() {
183 output.push_str(&format!(
184 " blocked_secrets: {}\n",
185 ds.blocked_secrets.len()
186 ));
187 }
188 if let Some(reason) = &ds.operation_denied_reason {
189 output.push_str(&format!(" denial_reason: {reason}\n"));
190 }
191 }
192
193 output
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn compliance_narrative_plaintext_format_is_readable() {
203 let narrative = ComplianceNarrative {
204 granted: Some(GrantedAuthority {
205 policy_source: "deploy".to_string(),
206 secret_count: 2,
207 trust_level: "hardened".to_string(),
208 inherit_mode: "minimal".to_string(),
209 output_redacted: true,
210 }),
211 target: Some(TargetDecisionChain {
212 command: "terraform".to_string(),
213 decision_code: "AllowedExact".to_string(),
214 decision_explanation: "matched allowlist by exact name".to_string(),
215 denial_reason: None,
216 }),
217 denied_stripped: Some(DeniedAndStripped {
218 stripped_env_names: vec!["HOME".to_string(), "PATH".to_string()],
219 blocked_secrets: vec![],
220 operation_denied_reason: None,
221 access_profile_notes: None,
222 }),
223 };
224
225 let text = narrative.to_plaintext();
226 assert!(text.contains("granted:"));
227 assert!(text.contains("policy_source: deploy"));
228 assert!(text.contains("target:"));
229 assert!(text.contains("command: terraform"));
230 assert!(text.contains("denied_stripped:"));
231 assert!(text.contains("stripped_env: HOME, PATH"));
232 }
233}