Skip to main content

vta_cli_common/commands/
acl.rs

1use ratatui::{
2    layout::Constraint,
3    style::{Color, Modifier, Style},
4    widgets::{Block, Cell, Row, Table},
5};
6use vta_sdk::acl::{ApproveScope, ContextDirection};
7use vta_sdk::client::ChangeAclRoleRequest;
8use vta_sdk::prelude::*;
9use vti_common::acl::{Role, act_scope_for};
10
11use crate::display::{
12    NAME_HEADER, NameBook, NameSource, book_from_acl, did_cell, full_display_pairs, name_cell,
13    named_did_cell, resolve_agent_names_into,
14};
15use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
16
17/// Human-readable context list — **role-aware**, because an empty
18/// `allowed_contexts` means opposite things depending on the role.
19///
20/// `AuthClaims::is_super_admin` requires `Role::Admin` *and* an empty list;
21/// `has_context_access` otherwise iterates `allowed_contexts`, and an empty
22/// list matches nothing. So empty means "every context" for an admin and
23/// "no context at all" for every other role.
24///
25/// Rendering both as `(unrestricted)` misled in both directions on a
26/// security-relevant display: a correctly-scoped least-privilege approver
27/// looked like a blanket grant, and an operator auditing for over-broad
28/// access saw `(unrestricted)` on rows that were in fact inert.
29pub fn format_contexts(role: &str, contexts: &[String]) -> String {
30    // The wire form carries the role as a string, so parse it back before
31    // decoding. An unrecognised role falls to the most restrictive reading:
32    // a display must never invent authority it cannot confirm.
33    //
34    // `format_role` already renders an unrestricted admin as "super admin", so
35    // the two columns read together without repeating the term.
36    let role = Role::parse(role).unwrap_or(Role::Monitor);
37    act_scope_for(&role, contexts).to_string()
38}
39
40pub fn format_role(role: &str, contexts: &[String]) -> String {
41    if role == "admin" && contexts.is_empty() {
42        "super admin".to_string()
43    } else {
44        role.to_string()
45    }
46}
47
48/// Human-readable approve-authority — what this entry may *confer* via an
49/// approval (task-consent delegation / step-up ratification) while acting
50/// nowhere. `None` when it confers nothing, so callers omit the line entirely.
51pub fn format_approve_scope(approve_all: bool, approve_contexts: &[String]) -> Option<String> {
52    if approve_all {
53        Some("all contexts".to_string())
54    } else if !approve_contexts.is_empty() {
55        Some(format!("contexts [{}]", approve_contexts.join(", ")))
56    } else {
57        None
58    }
59}
60
61/// Human-readable signing-key filter (#818). `None` (no filter) omits the
62/// line entirely; the empty filter is the state that must never be
63/// misrendered — "no keys" and "no filter" are opposite grants, so the empty
64/// set is spelled out rather than shown as a blank list.
65pub fn format_allowed_keys(allowed_keys: Option<&[String]>) -> Option<String> {
66    match allowed_keys {
67        None => None,
68        Some([]) => Some("(none — may invoke the signing oracle on no keys)".to_string()),
69        Some(keys) => Some(format!("keys [{}]", keys.join(", "))),
70    }
71}
72
73/// Resolve the two mutually-exclusive allowed-keys flags into the wire value.
74///
75/// `None` means "leave unchanged"; `Some(None)` clears the filter
76/// (`--allowed-keys-unrestricted`); `Some(Some(keys))` replaces it. Clearing
77/// needs its own flag for the same reason `--approve-none` does: an empty
78/// `--allowed-keys` cannot mean both "no keys at all" and "no filter".
79pub fn allowed_keys_from_flags(
80    allowed_keys: Option<Vec<String>>,
81    allowed_keys_unrestricted: bool,
82) -> Option<Option<Vec<String>>> {
83    if allowed_keys_unrestricted {
84        Some(None)
85    } else {
86        allowed_keys.map(Some)
87    }
88}
89
90pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
91    match role {
92        "admin" | "initiator" | "application" | "reader" => Ok(()),
93        _ => Err(format!(
94            "invalid role '{role}', expected: admin, initiator, application, or reader"
95        )
96        .into()),
97    }
98}
99
100/// Parse the operator's `--direction` value, defaulting to the historical
101/// "who may act in this context" reading and refusing anything else with the
102/// valid set — the same table the wire uses, so flag and payload cannot drift.
103pub fn parse_direction(
104    direction: Option<&str>,
105) -> Result<ContextDirection, Box<dyn std::error::Error>> {
106    match direction {
107        None => Ok(ContextDirection::default()),
108        Some(d) => Ok(d.parse::<ContextDirection>()?),
109    }
110}
111
112/// The question a `--context` + `--direction` pair actually asked, in words.
113///
114/// On screen because the two directions are equally plausible readings of the
115/// same flag and produce different lists: a subtree sweep that quietly ran as
116/// an act-in query returns the ancestors it is not revoking and looks like a
117/// complete answer (#822).
118pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
119    match direction {
120        ContextDirection::ActingIn => format!("able to act in {context}"),
121        ContextDirection::Subtree => format!("granted at or beneath {context}"),
122        ContextDirection::Any => format!("with authority touching {context}"),
123    }
124}
125
126pub async fn cmd_acl_list(
127    client: &VtaClient,
128    context: Option<&str>,
129    direction: Option<&str>,
130) -> Result<(), Box<dyn std::error::Error>> {
131    let direction = parse_direction(direction)?;
132    if context.is_none() && direction != ContextDirection::default() {
133        return Err(format!(
134            "--direction {direction} says how to read --context, and no --context was given.\n\
135             Try: pnm acl list --context <id> --direction {direction}"
136        )
137        .into());
138    }
139    let resp = client.list_acl_in_direction(context, direction).await?;
140
141    // `--json` short-circuits all rendering and emits a single JSON
142    // document. Empty result returns an empty array, NOT a printed
143    // "no entries" string — automation scripts depend on the JSON
144    // shape being consistent across populated and empty results.
145    if crate::render::is_json_output() {
146        crate::render::print_json(&resp.entries)?;
147        return Ok(());
148    }
149
150    if resp.entries.is_empty() {
151        // An empty subtree sweep is the answer that most needs qualifying:
152        // it can equally mean "nothing is granted beneath this context" and
153        // "you asked the other direction". Name the question that was asked.
154        match context {
155            Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
156            None => println!("No ACL entries found."),
157        }
158        return Ok(());
159    }
160
161    // One pass over the entries names every subject from its label — and,
162    // for free, the `Created By` column too, since a granting admin nearly
163    // always holds an ACL entry of their own.
164    let mut book = NameBook::new();
165    book_from_acl(&mut book, &resp.entries);
166    // Opt-in, and only over the DIDs actually on screen — including the
167    // `created_by` column, which is where an unfamiliar DID most often shows up.
168    resolve_agent_names_into(
169        &mut book,
170        resp.entries
171            .iter()
172            .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
173    )
174    .await;
175
176    // Name the question on screen whenever a context filter narrowed the
177    // answer. Two opposite readings of the same `--context` produce two
178    // legitimate, differently-shaped lists, and an operator who cannot see
179    // which one they got cannot tell a short list from a wrong one.
180    let heading = match context {
181        Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
182        None => "ACL Entries".to_string(),
183    };
184
185    if is_full_display() {
186        print_full_list_title(&heading, resp.entries.len());
187        for entry in &resp.entries {
188            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
189            let role = format_role(&entry.role, &entry.allowed_contexts);
190            let approve =
191                format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
192
193            // Name + full DID. Full display exists so an operator can copy a
194            // complete identifier, so the DID is never abbreviated here.
195            let mut fields = full_display_pairs(&book, &entry.did);
196            fields.push(("Role", role));
197            // The raw label is normally what the Name line already shows; keep
198            // it only when something higher-ranked (an agent name) displaced it.
199            if let Some(label) = entry.label.as_deref()
200                && book.name_of(&entry.did).as_deref() != Some(label)
201            {
202                fields.push(("Label", label.to_string()));
203            }
204            fields.push(("Contexts", contexts));
205            if let Some(k) = format_allowed_keys(entry.allowed_keys.as_deref()) {
206                fields.push(("Allowed Keys", k));
207            }
208            if let Some(a) = approve {
209                fields.push(("Approve", a));
210            }
211            fields.push(("Created By", book.render_inline(&entry.created_by)));
212            print_full_entry_owned(&fields);
213        }
214        return Ok(());
215    }
216
217    // Only give up a column to names if at least one entry has one — on a VTA
218    // where nothing has been labelled, a column of dashes is worse than no
219    // column.
220    let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
221
222    let header_style = Style::default()
223        .fg(Color::White)
224        .add_modifier(Modifier::BOLD);
225    let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
226    if show_names {
227        header_cells.insert(0, NAME_HEADER);
228    }
229    let header = Row::new(header_cells).style(header_style).bottom_margin(1);
230
231    let rows: Vec<Row> = resp
232        .entries
233        .iter()
234        .map(|entry| {
235            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
236            let mut cells = vec![
237                did_cell(&entry.did),
238                Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
239                Cell::from(contexts),
240                named_did_cell(&book, &entry.created_by),
241            ];
242            if show_names {
243                cells.insert(0, name_cell(&book, &entry.did));
244            }
245            Row::new(cells)
246        })
247        .collect();
248
249    let title = format!(" {heading} ({}) ", resp.entries.len());
250
251    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
252    // which frees the width the name column needs. `--full-display` and
253    // `--json` still carry every DID in full.
254    let mut constraints = vec![
255        Constraint::Min(34),    // DID
256        Constraint::Length(12), // Role
257        Constraint::Length(24), // Contexts
258        Constraint::Min(30),    // Created By
259    ];
260    if show_names {
261        constraints.insert(0, Constraint::Min(16));
262    }
263
264    let table = Table::new(rows, constraints)
265        .header(header)
266        .column_spacing(2)
267        .block(
268            Block::bordered()
269                .title(title)
270                .border_style(Style::default().fg(Color::DarkGray)),
271        );
272
273    let height = resp.entries.len() as u16 + 4;
274    print_widget(table, height);
275
276    Ok(())
277}
278
279pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
280    let entry = client.get_acl(did).await?;
281
282    let mut book = NameBook::new();
283    book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
284    resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
285
286    // Name above DID, DID in full — a single-entry view is where an operator
287    // copies an identifier from.
288    match book.name_of(&entry.did) {
289        Some(name) => {
290            println!("Name:             {name}");
291            println!("DID:              {}", entry.did);
292        }
293        None => println!("DID:              {}", entry.did),
294    }
295    println!(
296        "Role:             {}",
297        format_role(&entry.role, &entry.allowed_contexts)
298    );
299    // Normally the Name line above; shown separately only when something
300    // higher-ranked (a verified agent name) displaced the operator's label.
301    if let Some(label) = entry.label.as_deref()
302        && book.name_of(&entry.did).as_deref() != Some(label)
303    {
304        println!("Label:            {label}");
305    }
306    println!(
307        "Contexts:         {}",
308        format_contexts(&entry.role, &entry.allowed_contexts)
309    );
310    if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
311        println!("Allowed keys:     {keys}");
312    }
313    if let Some(scope) =
314        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
315    {
316        println!("Approve:          {scope}");
317    }
318    println!("Created At:       {}", entry.created_at);
319    println!("Created By:       {}", entry.created_by);
320    Ok(())
321}
322
323#[allow(clippy::too_many_arguments)]
324pub async fn cmd_acl_create(
325    client: &VtaClient,
326    did: String,
327    role: String,
328    label: Option<String>,
329    contexts: Vec<String>,
330    expires_at: Option<u64>,
331    step_up_approver: Option<String>,
332    step_up_require: Option<String>,
333    approve_all: bool,
334    approve_contexts: Vec<String>,
335    allowed_keys: Option<Vec<String>>,
336) -> Result<(), Box<dyn std::error::Error>> {
337    validate_role(&role)?;
338    let mut req = CreateAclRequest::new(did, role).contexts(contexts);
339    if let Some(keys) = allowed_keys {
340        req = req.allowed_keys(keys);
341    }
342    if let Some(l) = label {
343        req = req.label(l);
344    }
345    if let Some(secs) = expires_at {
346        req = req.expires_at(secs);
347    }
348    if let Some(ref approver) = step_up_approver {
349        req = req.step_up_approver(approver.clone());
350    }
351    if let Some(ref require) = step_up_require {
352        req = req.step_up_require(require.clone());
353    }
354    if approve_all {
355        req = req.approve_all();
356    } else if !approve_contexts.is_empty() {
357        req = req.approve_contexts(approve_contexts);
358    }
359    let entry = client.create_acl(req).await?;
360    println!("ACL entry created:");
361    println!("  DID:        {}", entry.did);
362    println!(
363        "  Role:       {}",
364        format_role(&entry.role, &entry.allowed_contexts)
365    );
366    if let Some(label) = &entry.label {
367        println!("  Label:      {label}");
368    }
369    println!(
370        "  Contexts:   {}",
371        format_contexts(&entry.role, &entry.allowed_contexts)
372    );
373    if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
374        println!("  Allowed keys: {keys}");
375    }
376    if let Some(scope) =
377        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
378    {
379        println!("  Approve:    {scope}");
380    }
381    if let Some(approver) = &step_up_approver {
382        println!("  Step-up approver: {approver}");
383    }
384    if let Some(require) = &step_up_require {
385        println!("  Step-up require:  {require}");
386    }
387    match entry.expires_at {
388        Some(secs) => println!(
389            "  Expires at: {} ({})",
390            crate::duration::format_local_time(secs),
391            crate::duration::format_remaining(secs),
392        ),
393        None => println!("  Expires at: (permanent)"),
394    }
395    Ok(())
396}
397
398/// Resolve the three mutually-exclusive approve flags into the wire value.
399///
400/// `None` means "leave unchanged" — which is why revoking needs its own flag
401/// rather than an empty `--approve-contexts`: an empty list cannot mean both
402/// "confer nothing" and "don't touch it".
403pub fn approve_scope_from_flags(
404    approve_all: bool,
405    approve_contexts: Option<Vec<String>>,
406    approve_none: bool,
407) -> Option<ApproveScope> {
408    if approve_none {
409        Some(ApproveScope::None)
410    } else if approve_all {
411        Some(ApproveScope::All)
412    } else {
413        approve_contexts.map(ApproveScope::Contexts)
414    }
415}
416
417/// `pnm acl change-role` — transition a subject's role with a
418/// compare-and-swap on the role they currently hold.
419pub async fn cmd_acl_change_role(
420    client: &VtaClient,
421    did: &str,
422    from_role: &str,
423    to_role: &str,
424    reason: Option<String>,
425) -> Result<(), Box<dyn std::error::Error>> {
426    validate_role(from_role)?;
427    validate_role(to_role)?;
428
429    let entry = client
430        .change_acl_role(
431            did,
432            ChangeAclRoleRequest {
433                from_role: from_role.to_string(),
434                to_role: to_role.to_string(),
435                reason,
436            },
437        )
438        .await?;
439
440    println!("ACL role changed:");
441    println!("  DID:  {}", entry.did);
442    println!(
443        "  Role: {} \u{2192} {}",
444        from_role,
445        format_role(&entry.role, &entry.allowed_contexts)
446    );
447    Ok(())
448}
449
450#[allow(clippy::too_many_arguments)]
451pub async fn cmd_acl_update(
452    client: &VtaClient,
453    did: &str,
454    role: Option<String>,
455    label: Option<String>,
456    contexts: Option<Vec<String>>,
457    step_up_approver: Option<String>,
458    step_up_require: Option<String>,
459    approve_scope: Option<ApproveScope>,
460    allowed_keys: Option<Option<Vec<String>>>,
461) -> Result<(), Box<dyn std::error::Error>> {
462    // Role transitions moved to `acl change-role`, which carries the
463    // compare-and-swap that makes a concurrent edit an error instead of a
464    // silent overwrite. Rather than just refusing, look up the role the
465    // subject actually holds so the operator can copy the fixed command —
466    // `--from` is the one argument they cannot guess.
467    if let Some(ref r) = role {
468        validate_role(r)?;
469        let current = client
470            .get_acl(did)
471            .await
472            .ok()
473            .map(|e| e.role)
474            .unwrap_or_else(|| "<current-role>".to_string());
475        return Err(format!(
476            "role changes are not part of `acl update` — they need the compare-and-swap that \
477             `acl change-role` carries.\n\n  Run: pnm acl change-role --did {did} --from \
478             {current} --to {r}"
479        )
480        .into());
481    }
482    let approve_scope_echo = approve_scope.clone();
483    let allowed_keys_echo = allowed_keys.clone();
484    let req = UpdateAclRequest {
485        label,
486        allowed_contexts: contexts,
487        step_up_approver: step_up_approver.clone(),
488        step_up_require: step_up_require.clone(),
489        approve_scope,
490        allowed_keys,
491    };
492    let entry = client.update_acl(did, req).await?;
493    println!("ACL entry updated:");
494    println!("  DID:      {}", entry.did);
495    println!(
496        "  Role:     {}",
497        format_role(&entry.role, &entry.allowed_contexts)
498    );
499    if let Some(label) = &entry.label {
500        println!("  Label:    {label}");
501    }
502    println!(
503        "  Contexts: {}",
504        format_contexts(&entry.role, &entry.allowed_contexts)
505    );
506    if let Some(approver) = &step_up_approver {
507        if approver.is_empty() {
508            println!("  Step-up approver: (cleared)");
509        } else {
510            println!("  Step-up approver: {approver}");
511        }
512    }
513    if let Some(require) = &step_up_require {
514        if require.is_empty() {
515            println!("  Step-up require:  (cleared)");
516        } else {
517            println!("  Step-up require:  {require}");
518        }
519    }
520    // Echo the filter only when this call set it — and spell out the two
521    // extremes, since "(cleared — every key in scope)" and "no keys at all"
522    // are the grants an operator most needs to see they just made.
523    if let Some(replacement) = &allowed_keys_echo {
524        let rendered = match replacement.as_deref() {
525            None => "(cleared — every key within the entry's contexts)".to_string(),
526            Some([]) => "(none — may invoke the signing oracle on no keys)".to_string(),
527            Some(keys) => format!("keys [{}]", keys.join(", ")),
528        };
529        println!("  Allowed keys: {rendered}");
530    }
531    // Echo the scope only when this call set it, so "unchanged" is visibly
532    // different from "set to confer nothing".
533    if let Some(scope) = &approve_scope_echo {
534        let rendered = match scope {
535            ApproveScope::None => "(revoked — confers nothing)".to_string(),
536            ApproveScope::All => "all contexts".to_string(),
537            ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
538        };
539        println!("  Approve:  {rendered}");
540    }
541    Ok(())
542}
543
544pub async fn cmd_acl_delete(
545    client: &VtaClient,
546    did: &str,
547) -> Result<(), Box<dyn std::error::Error>> {
548    client.delete_acl(did).await?;
549    println!("ACL entry deleted: {did}");
550    Ok(())
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    // ── format_contexts ────────────────────────────────────────────
558
559    /// Empty means "every context" only for an admin. This test previously
560    /// asserted `(unrestricted)` for an empty list regardless of role, which
561    /// pinned the bug rather than the behaviour.
562    #[test]
563    fn test_format_contexts_empty_is_role_dependent() {
564        assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
565        for role in ["reader", "initiator", "application"] {
566            assert_eq!(
567                format_contexts(role, &[]),
568                "(none — acts nowhere)",
569                "empty contexts must not read as unrestricted for role {role}"
570            );
571        }
572    }
573
574    /// The shape the `--approve-all` help text itself recommends: a reader
575    /// with no contexts whose authority is entirely `approve_scope`. It acts
576    /// nowhere, and the display must not suggest otherwise.
577    #[test]
578    fn test_least_privilege_approver_does_not_read_as_unrestricted() {
579        let contexts: Vec<String> = vec![];
580        assert_eq!(
581            format_contexts("reader", &contexts),
582            "(none — acts nowhere)"
583        );
584        assert_eq!(format_role("reader", &contexts), "reader");
585        assert_eq!(
586            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
587            Some("contexts [openvtc]")
588        );
589    }
590
591    #[test]
592    fn test_format_approve_scope() {
593        assert_eq!(
594            format_approve_scope(true, &[]).as_deref(),
595            Some("all contexts")
596        );
597        assert_eq!(
598            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
599            Some("contexts [openvtc]")
600        );
601        assert_eq!(
602            format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
603            Some("contexts [a, b]")
604        );
605        // Confers nothing ⇒ no line.
606        assert_eq!(format_approve_scope(false, &[]), None);
607    }
608
609    #[test]
610    fn test_format_contexts_single() {
611        let ctx = vec!["vta".to_string()];
612        assert_eq!(format_contexts("reader", &ctx), "vta");
613    }
614
615    #[test]
616    fn test_format_contexts_multiple() {
617        let ctx = vec!["vta".to_string(), "payments".to_string()];
618        assert_eq!(format_contexts("reader", &ctx), "vta, payments");
619    }
620
621    // ── format_allowed_keys (#818) ─────────────────────────────────
622
623    /// "No filter" and "no keys" are opposite grants; the display must never
624    /// blur them (the same lesson as #746 one axis over).
625    #[test]
626    fn test_format_allowed_keys_distinguishes_absent_from_empty() {
627        assert_eq!(format_allowed_keys(None), None, "no filter → no line");
628        assert_eq!(
629            format_allowed_keys(Some(&[])).as_deref(),
630            Some("(none — may invoke the signing oracle on no keys)")
631        );
632        assert_eq!(
633            format_allowed_keys(Some(&["k1".to_string(), "k2".to_string()])).as_deref(),
634            Some("keys [k1, k2]")
635        );
636    }
637
638    #[test]
639    fn test_allowed_keys_from_flags() {
640        // Neither flag → leave unchanged.
641        assert_eq!(allowed_keys_from_flags(None, false), None);
642        // Replace with exactly these ids.
643        assert_eq!(
644            allowed_keys_from_flags(Some(vec!["k1".into()]), false),
645            Some(Some(vec!["k1".to_string()]))
646        );
647        // Clear the filter — its own flag, because an empty `--allowed-keys`
648        // cannot mean both "no keys at all" and "no filter".
649        assert_eq!(allowed_keys_from_flags(None, true), Some(None));
650    }
651
652    // ── format_role ────────────────────────────────────────────────
653
654    #[test]
655    fn test_format_role_admin_no_contexts_is_super_admin() {
656        assert_eq!(format_role("admin", &[]), "super admin");
657    }
658
659    #[test]
660    fn test_format_role_admin_with_contexts_stays_admin() {
661        let ctx = vec!["vta".to_string()];
662        assert_eq!(format_role("admin", &ctx), "admin");
663    }
664
665    #[test]
666    fn test_format_role_initiator_unchanged() {
667        assert_eq!(format_role("initiator", &[]), "initiator");
668    }
669
670    #[test]
671    fn test_format_role_application_unchanged() {
672        let ctx = vec!["app".to_string()];
673        assert_eq!(format_role("application", &ctx), "application");
674    }
675
676    // ── validate_role ──────────────────────────────────────────────
677
678    #[test]
679    fn test_validate_role_admin_ok() {
680        assert!(validate_role("admin").is_ok());
681    }
682
683    #[test]
684    fn test_validate_role_initiator_ok() {
685        assert!(validate_role("initiator").is_ok());
686    }
687
688    #[test]
689    fn test_validate_role_application_ok() {
690        assert!(validate_role("application").is_ok());
691    }
692
693    #[test]
694    fn test_validate_role_reader_ok() {
695        assert!(validate_role("reader").is_ok());
696    }
697
698    #[test]
699    fn test_validate_role_unknown_fails() {
700        let err = validate_role("superuser").unwrap_err();
701        assert!(err.to_string().contains("invalid role 'superuser'"));
702    }
703
704    #[test]
705    fn test_validate_role_empty_fails() {
706        assert!(validate_role("").is_err());
707    }
708}