Skip to main content

vta_cli_common/commands/
approvals.rs

1//! `… approvals …` — which tasks need an additional human decision.
2//!
3//! One surface over the reserved declarative policy row: the rules, the named
4//! approver sets, and what a given task actually requires. Everything here is a
5//! read-modify-write of that one row through the canonical `policy/{get,upsert}`
6//! tasks, carrying `expectedVersion` so two operators editing at once get a
7//! conflict rather than a silent last-writer-wins.
8//!
9//! # The Rego is generated here
10//!
11//! Canonical `policy/upsert` treats `module` as client-authored, so these
12//! commands run [`vta_sdk::approvals::synthesize_rego`] over the rules and send
13//! the result alongside them. The VTA re-derives and byte-compares. That is why
14//! there is no `--module` escape hatch on this surface: a declarative row whose
15//! Rego said something other than its rules would make everything printed here
16//! a lie. Hand-authored Rego belongs to `… policy upsert`.
17
18use 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
31/// The declarative row as the CLI holds it: the model plus the version it was
32/// read at, so a write can be conditional on nothing having moved underneath.
33struct Model {
34    rules: Vec<ApprovalRule>,
35    approver_sets: ApproverSets,
36    /// Version of the stored row; `0` when there is no row yet.
37    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        // No row yet: an empty model, which is also the shipping default (a VTA
63        // that has never had an approval rule gates nothing).
64        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
73/// Validate locally, then write the row back conditionally on `version`.
74///
75/// Validating client-side first is not a shortcut around the server's check —
76/// the VTA validates too, and its answer is the one that counts. It is so an
77/// operator sees the same sentence without a round trip, and so a mistyped set
78/// name is caught before it is sent.
79async 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
101/// `approvals list` — the rules and the sets they draw on.
102pub async fn cmd_list(client: &VtaClient) -> CmdResult {
103    let model = load(client).await?;
104    render_model(&model.rules, &model.approver_sets)
105}
106
107/// Print the declarative model — the rules and the sets they draw on.
108///
109/// Shared with the **offline** `vta approvals list` break-glass, which reads the
110/// same row straight from fjall when the wire path is unreachable. One renderer
111/// on purpose: an operator diagnosing a lockout is comparing what the offline
112/// command prints against what they remember `pnm approvals list` printing, and
113/// two implementations of "what does this VTA require" would eventually disagree
114/// at exactly the moment that comparison matters most.
115pub 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
168/// `approvals require <task-uri> …` — add or replace the rule for a task type.
169///
170/// Replaces rather than appends when a rule with the same task type and the same
171/// scope already exists: an operator saying "acl/grant needs consent" after
172/// saying "acl/grant needs reauth" means the second, and appending would produce
173/// two overlapping guards that `validate` refuses anyway.
174pub 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        // Only send the flag when set: `Some(false)` and `None` mean the same
191        // thing, and the rule shape refuses consent-only members on a reauth
192        // rule, so an unconditional `Some(false)` would make `--reauth` fail.
193        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
207/// `approvals remove <task-uri>` — drop the rule(s) for a task type.
208pub 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
226/// `approvals approvers add <set> <did>`.
227pub 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
240/// `approvals approvers remove <set> <did>`.
241///
242/// Refuses a removal that would leave a rule unsatisfiable, rather than letting
243/// the set quietly fall below a threshold and discovering it at the next gated
244/// request. Server-side `validate` refuses it too; this is the same rule stated
245/// early and in the operator's own terms.
246pub 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
264/// `approvals explain <task-uri>` — what does this task require, and can it be
265/// satisfied?
266///
267/// The question that made this whole surface necessary: a `pnm contexts create`
268/// failed with `auth:step_up_required`, and the policy the operator was reading
269/// was not the policy that fired. This answers from the rules that decide.
270pub 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    // Same precedence the VTA applies: a context-scoped rule beats an unscoped
279    // one for the same task type.
280    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}