gardesk/garcard / d4699f7

Browse files

Add multi-identity selection prompt

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d4699f75d572f644171daf4f9f3ffef5c87ef561
Parents
1b0b34c
Tree
ae1a3e4

1 changed file

StatusFile+-
M garcard/src/agent.rs 207 6
garcard/src/agent.rsmodified
@@ -1,5 +1,5 @@
11
 use crate::polkit_helper::{
2
-    DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider,
2
+    DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, PromptResponse,
33
 };
44
 use crate::prompt::CommandPrompt;
55
 use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert};
@@ -56,6 +56,7 @@ pub struct PolkitBackendConfig {
5656
 type Subject = (String, HashMap<String, OwnedValue>);
5757
 type Details = HashMap<String, String>;
5858
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
59
+const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
5960
 
6061
 #[derive(Debug)]
6162
 struct AuthRequest {
@@ -76,6 +77,7 @@ struct ActiveRequest {
7677
     details: Details,
7778
     cookie: String,
7879
     username: String,
80
+    identity_options: Vec<String>,
7981
 }
8082
 
8183
 #[derive(Debug)]
@@ -323,6 +325,13 @@ impl PolkitRuntime {
323325
             .or_else(current_username)
324326
             .or_else(|| std::env::var("USER").ok())
325327
             .unwrap_or_else(|| "unknown".to_string());
328
+        let mut identity_options = identity_options_from_subjects(&request.identities);
329
+        if !identity_options
330
+            .iter()
331
+            .any(|candidate| candidate == &username)
332
+        {
333
+            identity_options.insert(0, username.clone());
334
+        }
326335
 
327336
         tracing::debug!(
328337
             identity_summary = %summarize_identities(&request.identities),
@@ -338,6 +347,7 @@ impl PolkitRuntime {
338347
             details: request.details.clone(),
339348
             cookie: request.cookie.clone(),
340349
             username,
350
+            identity_options,
341351
         })
342352
     }
343353
 
@@ -353,6 +363,10 @@ impl PolkitRuntime {
353363
     ) -> HelperOutcome {
354364
         let prompt_context = render_prompt_context(request);
355365
         let max_attempts = auth_max_attempts();
366
+        let username = match select_identity_for_request(request, prompts) {
367
+            IdentitySelection::Selected(selected) => selected,
368
+            IdentitySelection::Terminal(outcome) => return outcome,
369
+        };
356370
 
357371
         for attempt in 1..=max_attempts {
358372
             if self.is_canceled(&request.cookie) {
@@ -371,11 +385,10 @@ impl PolkitRuntime {
371385
 
372386
             let mut cancel_aware =
373387
                 CancellationAwarePrompt::new(prompts, self, request.cookie.as_str());
374
-            match self.helper_client.authenticate(
375
-                &request.username,
376
-                &request.cookie,
377
-                &mut cancel_aware,
378
-            ) {
388
+            match self
389
+                .helper_client
390
+                .authenticate(&username, &request.cookie, &mut cancel_aware)
391
+            {
379392
                 Ok(outcome) if self.is_canceled(&request.cookie) => {
380393
                     tracing::info!(
381394
                         action_id = %request.action_id,
@@ -558,6 +571,123 @@ fn render_prompt_context(request: &ActiveRequest) -> String {
558571
     lines.join("\n")
559572
 }
560573
 
574
+enum IdentitySelection {
575
+    Selected(String),
576
+    Terminal(HelperOutcome),
577
+}
578
+
579
+fn select_identity_for_request<P: PromptProvider>(
580
+    request: &ActiveRequest,
581
+    prompts: &mut P,
582
+) -> IdentitySelection {
583
+    if request.identity_options.len() <= 1 {
584
+        return IdentitySelection::Selected(request.username.clone());
585
+    }
586
+
587
+    let prompt = render_identity_selection_prompt(request);
588
+    for attempt in 1..=DEFAULT_IDENTITY_SELECTION_ATTEMPTS {
589
+        match prompts.prompt_plain(&prompt) {
590
+            Ok(PromptResponse::Submitted(mut raw)) => {
591
+                if let Some(selected) = parse_identity_selection(
592
+                    raw.as_str(),
593
+                    &request.identity_options,
594
+                    &request.username,
595
+                ) {
596
+                    raw.clear();
597
+                    return IdentitySelection::Selected(selected);
598
+                }
599
+
600
+                raw.clear();
601
+                let _ = prompts.show_error("Invalid identity selection");
602
+                if attempt == DEFAULT_IDENTITY_SELECTION_ATTEMPTS {
603
+                    tracing::warn!(
604
+                        action_id = %request.action_id,
605
+                        default_identity = %request.username,
606
+                        "Identity selection failed repeatedly; using default identity"
607
+                    );
608
+                    return IdentitySelection::Selected(request.username.clone());
609
+                }
610
+            }
611
+            Ok(PromptResponse::Canceled) => {
612
+                return IdentitySelection::Terminal(HelperOutcome::Canceled);
613
+            }
614
+            Ok(PromptResponse::TimedOut) => {
615
+                return IdentitySelection::Terminal(HelperOutcome::Timeout);
616
+            }
617
+            Err(err) => {
618
+                tracing::warn!(
619
+                    action_id = %request.action_id,
620
+                    error = %err,
621
+                    default_identity = %request.username,
622
+                    "Identity selection prompt failed; using default identity"
623
+                );
624
+                return IdentitySelection::Selected(request.username.clone());
625
+            }
626
+        }
627
+    }
628
+
629
+    IdentitySelection::Selected(request.username.clone())
630
+}
631
+
632
+fn render_identity_selection_prompt(request: &ActiveRequest) -> String {
633
+    let mut lines = vec![
634
+        "Select authentication identity".to_string(),
635
+        format!("Action: {}", request.action_id),
636
+    ];
637
+    for (index, option) in request.identity_options.iter().enumerate() {
638
+        if option == &request.username {
639
+            lines.push(format!("{}: {} (default)", index + 1, option));
640
+        } else {
641
+            lines.push(format!("{}: {}", index + 1, option));
642
+        }
643
+    }
644
+    lines.push("Enter number or username (blank for default)".to_string());
645
+    lines.join("\n")
646
+}
647
+
648
+fn parse_identity_selection(input: &str, options: &[String], default: &str) -> Option<String> {
649
+    let trimmed = input.trim();
650
+    if trimmed.is_empty() {
651
+        return Some(default.to_string());
652
+    }
653
+
654
+    if let Ok(index) = trimmed.parse::<usize>() {
655
+        if (1..=options.len()).contains(&index) {
656
+            return options.get(index - 1).cloned();
657
+        }
658
+    }
659
+
660
+    options
661
+        .iter()
662
+        .find(|option| option.eq_ignore_ascii_case(trimmed))
663
+        .cloned()
664
+}
665
+
666
+fn identity_options_from_subjects(identities: &[Subject]) -> Vec<String> {
667
+    let mut options = Vec::new();
668
+    let mut seen = HashSet::new();
669
+
670
+    for (kind, details) in identities {
671
+        if kind != "unix-user" {
672
+            continue;
673
+        }
674
+
675
+        let Some(name) = identity_name(details) else {
676
+            continue;
677
+        };
678
+        let trimmed = name.trim();
679
+        if trimmed.is_empty() {
680
+            continue;
681
+        }
682
+        let dedupe_key = trimmed.to_ascii_lowercase();
683
+        if seen.insert(dedupe_key) {
684
+            options.push(trimmed.to_string());
685
+        }
686
+    }
687
+
688
+    options
689
+}
690
+
561691
 fn detail_key_label(key: &str) -> &'static str {
562692
     match key {
563693
         "program" => "Program",
@@ -1042,6 +1172,7 @@ mod tests {
10421172
         responses: VecDeque<PromptResponse>,
10431173
         success_count: usize,
10441174
         failure_messages: Vec<String>,
1175
+        error_messages: Vec<String>,
10451176
         prompt_count: usize,
10461177
     }
10471178
 
@@ -1051,6 +1182,7 @@ mod tests {
10511182
                 responses: VecDeque::from(responses),
10521183
                 success_count: 0,
10531184
                 failure_messages: Vec::new(),
1185
+                error_messages: Vec::new(),
10541186
                 prompt_count: 0,
10551187
             }
10561188
         }
@@ -1078,6 +1210,11 @@ mod tests {
10781210
             self.failure_messages.push(message.to_string());
10791211
             Ok(())
10801212
         }
1213
+
1214
+        fn show_error(&mut self, message: &str) -> Result<()> {
1215
+            self.error_messages.push(message.to_string());
1216
+            Ok(())
1217
+        }
10811218
     }
10821219
 
10831220
     impl RetryPromptProvider for SequencedPrompt {}
@@ -1323,6 +1460,7 @@ mod tests {
13231460
             details: HashMap::new(),
13241461
             cookie: "cookie-1".to_string(),
13251462
             username: "alice".to_string(),
1463
+            identity_options: vec!["alice".to_string()],
13261464
         };
13271465
         let mut prompts = SequencedPrompt::new(vec![
13281466
             PromptResponse::Submitted("correct horse".to_string()),
@@ -1380,6 +1518,7 @@ mod tests {
13801518
             details: HashMap::new(),
13811519
             cookie: "cookie-timeout".to_string(),
13821520
             username: "alice".to_string(),
1521
+            identity_options: vec!["alice".to_string()],
13831522
         };
13841523
         let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]);
13851524
 
@@ -1410,6 +1549,7 @@ mod tests {
14101549
             details,
14111550
             cookie: "cookie-ctx".to_string(),
14121551
             username: "alice".to_string(),
1552
+            identity_options: vec!["alice".to_string()],
14131553
         };
14141554
 
14151555
         let context = render_prompt_context(&request);
@@ -1421,6 +1561,67 @@ mod tests {
14211561
         assert!(context.contains("Retains authorization: 1"));
14221562
     }
14231563
 
1564
+    #[test]
1565
+    fn parse_identity_selection_accepts_blank_index_and_name() {
1566
+        let options = vec!["alice".to_string(), "root".to_string()];
1567
+        assert_eq!(
1568
+            parse_identity_selection("", &options, "alice"),
1569
+            Some("alice".to_string())
1570
+        );
1571
+        assert_eq!(
1572
+            parse_identity_selection("2", &options, "alice"),
1573
+            Some("root".to_string())
1574
+        );
1575
+        assert_eq!(
1576
+            parse_identity_selection("ROOT", &options, "alice"),
1577
+            Some("root".to_string())
1578
+        );
1579
+        assert_eq!(parse_identity_selection("99", &options, "alice"), None);
1580
+    }
1581
+
1582
+    #[test]
1583
+    fn select_identity_for_request_uses_prompted_choice() {
1584
+        let request = ActiveRequest {
1585
+            action_id: "org.gardesk.test".to_string(),
1586
+            message: "Authenticate".to_string(),
1587
+            icon_name: "dialog-password".to_string(),
1588
+            detail_count: 0,
1589
+            details: HashMap::new(),
1590
+            cookie: "cookie-identity".to_string(),
1591
+            username: "alice".to_string(),
1592
+            identity_options: vec!["alice".to_string(), "root".to_string()],
1593
+        };
1594
+        let mut prompts = SequencedPrompt::new(vec![PromptResponse::Submitted("2".to_string())]);
1595
+
1596
+        let selection = select_identity_for_request(&request, &mut prompts);
1597
+        assert!(matches!(
1598
+            selection,
1599
+            IdentitySelection::Selected(username) if username == "root"
1600
+        ));
1601
+        assert_eq!(prompts.prompt_count, 1);
1602
+    }
1603
+
1604
+    #[test]
1605
+    fn select_identity_for_request_returns_canceled_outcome() {
1606
+        let request = ActiveRequest {
1607
+            action_id: "org.gardesk.test".to_string(),
1608
+            message: "Authenticate".to_string(),
1609
+            icon_name: "dialog-password".to_string(),
1610
+            detail_count: 0,
1611
+            details: HashMap::new(),
1612
+            cookie: "cookie-identity-cancel".to_string(),
1613
+            username: "alice".to_string(),
1614
+            identity_options: vec!["alice".to_string(), "root".to_string()],
1615
+        };
1616
+        let mut prompts = SequencedPrompt::new(vec![PromptResponse::Canceled]);
1617
+
1618
+        let selection = select_identity_for_request(&request, &mut prompts);
1619
+        assert!(matches!(
1620
+            selection,
1621
+            IdentitySelection::Terminal(HelperOutcome::Canceled)
1622
+        ));
1623
+    }
1624
+
14241625
     #[test]
14251626
     fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() {
14261627
         let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default()));