1use std::collections::BTreeMap;
19
20use vta_sdk::approvals::{
21 ApprovalRule, ApproverSets, DECLARATIVE_POLICY_ID, DECLARATIVE_POLICY_NAME,
22 DECLARATIVE_POLICY_PRIORITY, EXT_KEY_APPROVER_SETS, EXT_KEY_RULES, Requires, synthesize_rego,
23 validate,
24};
25use vta_sdk::error::VtaError;
26use vta_sdk::prelude::*;
27use vta_sdk::protocols::policy_management::UpsertPolicyBody;
28
29type CmdResult = Result<(), Box<dyn std::error::Error>>;
30
31struct Model {
34 rules: Vec<ApprovalRule>,
35 approver_sets: ApproverSets,
36 version: u64,
38}
39
40async fn load(client: &VtaClient) -> Result<Model, Box<dyn std::error::Error>> {
41 match client.get_policy(DECLARATIVE_POLICY_ID).await {
42 Ok(resp) => {
43 let ext = &resp.policy.ext;
44 let rules = ext
45 .get(EXT_KEY_RULES)
46 .cloned()
47 .map(serde_json::from_value)
48 .transpose()?
49 .unwrap_or_default();
50 let approver_sets = ext
51 .get(EXT_KEY_APPROVER_SETS)
52 .cloned()
53 .map(serde_json::from_value)
54 .transpose()?
55 .unwrap_or_default();
56 Ok(Model {
57 rules,
58 approver_sets,
59 version: resp.policy.version,
60 })
61 }
62 Err(VtaError::NotFound(_)) => Ok(Model {
65 rules: Vec::new(),
66 approver_sets: BTreeMap::new(),
67 version: 0,
68 }),
69 Err(e) => Err(e.into()),
70 }
71}
72
73async fn save(client: &VtaClient, model: Model) -> CmdResult {
80 validate(&model.rules, &model.approver_sets)?;
81 let module = synthesize_rego(&model.rules);
82 client
83 .upsert_policy(UpsertPolicyBody {
84 id: Some(DECLARATIVE_POLICY_ID.to_string()),
85 name: DECLARATIVE_POLICY_NAME.to_string(),
86 description: None,
87 module,
88 applies_to: vec![],
89 priority: Some(DECLARATIVE_POLICY_PRIORITY),
90 enabled: true,
91 expected_version: Some(model.version),
92 ext: serde_json::json!({
93 EXT_KEY_RULES: model.rules,
94 EXT_KEY_APPROVER_SETS: model.approver_sets,
95 }),
96 })
97 .await?;
98 Ok(())
99}
100
101pub async fn cmd_list(client: &VtaClient) -> CmdResult {
103 let model = load(client).await?;
104 render_model(&model.rules, &model.approver_sets)
105}
106
107pub fn render_model(rules: &[ApprovalRule], approver_sets: &ApproverSets) -> CmdResult {
116 if crate::render::is_json_output() {
117 println!(
118 "{}",
119 serde_json::to_string_pretty(&serde_json::json!({
120 "rules": rules,
121 "approverSets": approver_sets,
122 }))?
123 );
124 return Ok(());
125 }
126
127 if rules.is_empty() {
128 println!("No approval rules — every task runs on the caller's own authority.");
129 } else {
130 println!("Approval rules:");
131 for rule in rules {
132 println!(" {}", rule.task_type);
133 match rule.requires {
134 Requires::Reauth => {
135 println!(" requires re-authentication (AAL2) by the caller");
136 }
137 Requires::Consent => {
138 println!(
139 " requires consent — {} approval(s) from set `{}`{}",
140 rule.effective_min_approvals(),
141 rule.approver_set.as_deref().unwrap_or("?"),
142 if rule.effective_exclude_requester() {
143 ", requester excluded"
144 } else {
145 ""
146 }
147 );
148 }
149 }
150 if !rule.contexts.is_empty() {
151 println!(" contexts {}", rule.contexts.join(", "));
152 }
153 }
154 }
155
156 if !approver_sets.is_empty() {
157 println!("\nApprover sets:");
158 for (name, members) in approver_sets {
159 println!(" {name}");
160 for did in members {
161 println!(" {did}");
162 }
163 }
164 }
165 Ok(())
166}
167
168pub async fn cmd_require(
175 client: &VtaClient,
176 task_type: String,
177 requires: Requires,
178 approver_set: Option<String>,
179 min_approvals: Option<u32>,
180 exclude_requester: bool,
181 contexts: Vec<String>,
182) -> CmdResult {
183 let mut model = load(client).await?;
184
185 let rule = ApprovalRule {
186 task_type: task_type.clone(),
187 requires,
188 approver_set,
189 min_approvals,
190 exclude_requester: exclude_requester.then_some(true),
194 contexts: contexts.clone(),
195 };
196
197 model
198 .rules
199 .retain(|r| !(r.task_type == task_type && r.contexts == contexts));
200 model.rules.push(rule);
201
202 save(client, model).await?;
203 println!("Approval rule set for {task_type}.");
204 Ok(())
205}
206
207pub async fn cmd_remove(
209 client: &VtaClient,
210 task_type: String,
211 contexts: Option<Vec<String>>,
212) -> CmdResult {
213 let mut model = load(client).await?;
214 let before = model.rules.len();
215 model.rules.retain(|r| {
216 r.task_type != task_type || contexts.as_ref().is_some_and(|c| &r.contexts != c)
217 });
218 if model.rules.len() == before {
219 return Err(format!("no approval rule for {task_type}").into());
220 }
221 save(client, model).await?;
222 println!("Approval rule removed for {task_type}.");
223 Ok(())
224}
225
226pub async fn cmd_approver_add(client: &VtaClient, set: String, did: String) -> CmdResult {
228 let mut model = load(client).await?;
229 let members = model.approver_sets.entry(set.clone()).or_default();
230 if members.contains(&did) {
231 println!("{did} is already in `{set}`.");
232 return Ok(());
233 }
234 members.push(did.clone());
235 save(client, model).await?;
236 println!("Added {did} to approver set `{set}`.");
237 Ok(())
238}
239
240pub async fn cmd_approver_remove(client: &VtaClient, set: String, did: String) -> CmdResult {
247 let mut model = load(client).await?;
248 let Some(members) = model.approver_sets.get_mut(&set) else {
249 return Err(format!("no approver set `{set}`").into());
250 };
251 let before = members.len();
252 members.retain(|m| m != &did);
253 if members.len() == before {
254 return Err(format!("{did} is not in approver set `{set}`").into());
255 }
256 if members.is_empty() {
257 model.approver_sets.remove(&set);
258 }
259 save(client, model).await?;
260 println!("Removed {did} from approver set `{set}`.");
261 Ok(())
262}
263
264pub async fn cmd_explain(
271 client: &VtaClient,
272 task_type: String,
273 context: Option<String>,
274) -> CmdResult {
275 let model = load(client).await?;
276 let ctx = context.as_deref().unwrap_or("default");
277
278 let matched = model
281 .rules
282 .iter()
283 .find(|r| r.task_type == task_type && r.contexts.iter().any(|c| c == ctx))
284 .or_else(|| {
285 model
286 .rules
287 .iter()
288 .find(|r| r.task_type == task_type && r.contexts.is_empty())
289 });
290
291 println!("{task_type}");
292 println!(" context: {ctx}");
293 match matched {
294 None => {
295 println!(" requires: nothing — no rule names this task");
296 println!(
297 "\n (a hand-authored policy could still gate it; `{} policy list` shows those)",
298 crate::render::bin_name()
299 );
300 }
301 Some(rule) => match rule.requires {
302 Requires::Reauth => {
303 println!(" requires: re-authentication (AAL2) by the caller");
304 println!(
305 "\n Satisfy it by elevating this session, then re-running the command.\n \
306 Remove the requirement with:\n {} approvals remove {task_type}",
307 crate::render::bin_name()
308 );
309 }
310 Requires::Consent => {
311 let set = rule.approver_set.as_deref().unwrap_or("?");
312 let members = model.approver_sets.get(set);
313 let min = rule.effective_min_approvals();
314 println!(
315 " requires: consent — {min} approval(s) from set `{set}`{}",
316 if rule.effective_exclude_requester() {
317 ", requester excluded"
318 } else {
319 ""
320 }
321 );
322 match members {
323 None => println!(
324 " approvers: set `{set}` is NOT DEFINED — this task can never run"
325 ),
326 Some(m) if (m.len() as u32) < min => println!(
327 " approvers: {} member(s) — fewer than the {min} required, so this task \
328 can never run",
329 m.len()
330 ),
331 Some(m) => {
332 println!(" approvers:");
333 for did in m {
334 println!(" {did}");
335 }
336 }
337 }
338 }
339 },
340 }
341 Ok(())
342}