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 @@
1
 use crate::polkit_helper::{
1
 use crate::polkit_helper::{
2
-    DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider,
2
+    DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, PromptResponse,
3
 };
3
 };
4
 use crate::prompt::CommandPrompt;
4
 use crate::prompt::CommandPrompt;
5
 use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert};
5
 use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert};
@@ -56,6 +56,7 @@ pub struct PolkitBackendConfig {
56
 type Subject = (String, HashMap<String, OwnedValue>);
56
 type Subject = (String, HashMap<String, OwnedValue>);
57
 type Details = HashMap<String, String>;
57
 type Details = HashMap<String, String>;
58
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
58
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
59
+const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
59
 
60
 
60
 #[derive(Debug)]
61
 #[derive(Debug)]
61
 struct AuthRequest {
62
 struct AuthRequest {
@@ -76,6 +77,7 @@ struct ActiveRequest {
76
     details: Details,
77
     details: Details,
77
     cookie: String,
78
     cookie: String,
78
     username: String,
79
     username: String,
80
+    identity_options: Vec<String>,
79
 }
81
 }
80
 
82
 
81
 #[derive(Debug)]
83
 #[derive(Debug)]
@@ -323,6 +325,13 @@ impl PolkitRuntime {
323
             .or_else(current_username)
325
             .or_else(current_username)
324
             .or_else(|| std::env::var("USER").ok())
326
             .or_else(|| std::env::var("USER").ok())
325
             .unwrap_or_else(|| "unknown".to_string());
327
             .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
+        }
326
 
335
 
327
         tracing::debug!(
336
         tracing::debug!(
328
             identity_summary = %summarize_identities(&request.identities),
337
             identity_summary = %summarize_identities(&request.identities),
@@ -338,6 +347,7 @@ impl PolkitRuntime {
338
             details: request.details.clone(),
347
             details: request.details.clone(),
339
             cookie: request.cookie.clone(),
348
             cookie: request.cookie.clone(),
340
             username,
349
             username,
350
+            identity_options,
341
         })
351
         })
342
     }
352
     }
343
 
353
 
@@ -353,6 +363,10 @@ impl PolkitRuntime {
353
     ) -> HelperOutcome {
363
     ) -> HelperOutcome {
354
         let prompt_context = render_prompt_context(request);
364
         let prompt_context = render_prompt_context(request);
355
         let max_attempts = auth_max_attempts();
365
         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
+        };
356
 
370
 
357
         for attempt in 1..=max_attempts {
371
         for attempt in 1..=max_attempts {
358
             if self.is_canceled(&request.cookie) {
372
             if self.is_canceled(&request.cookie) {
@@ -371,11 +385,10 @@ impl PolkitRuntime {
371
 
385
 
372
             let mut cancel_aware =
386
             let mut cancel_aware =
373
                 CancellationAwarePrompt::new(prompts, self, request.cookie.as_str());
387
                 CancellationAwarePrompt::new(prompts, self, request.cookie.as_str());
374
-            match self.helper_client.authenticate(
388
+            match self
375
-                &request.username,
389
+                .helper_client
376
-                &request.cookie,
390
+                .authenticate(&username, &request.cookie, &mut cancel_aware)
377
-                &mut cancel_aware,
391
+            {
378
-            ) {
379
                 Ok(outcome) if self.is_canceled(&request.cookie) => {
392
                 Ok(outcome) if self.is_canceled(&request.cookie) => {
380
                     tracing::info!(
393
                     tracing::info!(
381
                         action_id = %request.action_id,
394
                         action_id = %request.action_id,
@@ -558,6 +571,123 @@ fn render_prompt_context(request: &ActiveRequest) -> String {
558
     lines.join("\n")
571
     lines.join("\n")
559
 }
572
 }
560
 
573
 
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
+
561
 fn detail_key_label(key: &str) -> &'static str {
691
 fn detail_key_label(key: &str) -> &'static str {
562
     match key {
692
     match key {
563
         "program" => "Program",
693
         "program" => "Program",
@@ -1042,6 +1172,7 @@ mod tests {
1042
         responses: VecDeque<PromptResponse>,
1172
         responses: VecDeque<PromptResponse>,
1043
         success_count: usize,
1173
         success_count: usize,
1044
         failure_messages: Vec<String>,
1174
         failure_messages: Vec<String>,
1175
+        error_messages: Vec<String>,
1045
         prompt_count: usize,
1176
         prompt_count: usize,
1046
     }
1177
     }
1047
 
1178
 
@@ -1051,6 +1182,7 @@ mod tests {
1051
                 responses: VecDeque::from(responses),
1182
                 responses: VecDeque::from(responses),
1052
                 success_count: 0,
1183
                 success_count: 0,
1053
                 failure_messages: Vec::new(),
1184
                 failure_messages: Vec::new(),
1185
+                error_messages: Vec::new(),
1054
                 prompt_count: 0,
1186
                 prompt_count: 0,
1055
             }
1187
             }
1056
         }
1188
         }
@@ -1078,6 +1210,11 @@ mod tests {
1078
             self.failure_messages.push(message.to_string());
1210
             self.failure_messages.push(message.to_string());
1079
             Ok(())
1211
             Ok(())
1080
         }
1212
         }
1213
+
1214
+        fn show_error(&mut self, message: &str) -> Result<()> {
1215
+            self.error_messages.push(message.to_string());
1216
+            Ok(())
1217
+        }
1081
     }
1218
     }
1082
 
1219
 
1083
     impl RetryPromptProvider for SequencedPrompt {}
1220
     impl RetryPromptProvider for SequencedPrompt {}
@@ -1323,6 +1460,7 @@ mod tests {
1323
             details: HashMap::new(),
1460
             details: HashMap::new(),
1324
             cookie: "cookie-1".to_string(),
1461
             cookie: "cookie-1".to_string(),
1325
             username: "alice".to_string(),
1462
             username: "alice".to_string(),
1463
+            identity_options: vec!["alice".to_string()],
1326
         };
1464
         };
1327
         let mut prompts = SequencedPrompt::new(vec![
1465
         let mut prompts = SequencedPrompt::new(vec![
1328
             PromptResponse::Submitted("correct horse".to_string()),
1466
             PromptResponse::Submitted("correct horse".to_string()),
@@ -1380,6 +1518,7 @@ mod tests {
1380
             details: HashMap::new(),
1518
             details: HashMap::new(),
1381
             cookie: "cookie-timeout".to_string(),
1519
             cookie: "cookie-timeout".to_string(),
1382
             username: "alice".to_string(),
1520
             username: "alice".to_string(),
1521
+            identity_options: vec!["alice".to_string()],
1383
         };
1522
         };
1384
         let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]);
1523
         let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]);
1385
 
1524
 
@@ -1410,6 +1549,7 @@ mod tests {
1410
             details,
1549
             details,
1411
             cookie: "cookie-ctx".to_string(),
1550
             cookie: "cookie-ctx".to_string(),
1412
             username: "alice".to_string(),
1551
             username: "alice".to_string(),
1552
+            identity_options: vec!["alice".to_string()],
1413
         };
1553
         };
1414
 
1554
 
1415
         let context = render_prompt_context(&request);
1555
         let context = render_prompt_context(&request);
@@ -1421,6 +1561,67 @@ mod tests {
1421
         assert!(context.contains("Retains authorization: 1"));
1561
         assert!(context.contains("Retains authorization: 1"));
1422
     }
1562
     }
1423
 
1563
 
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
+
1424
     #[test]
1625
     #[test]
1425
     fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() {
1626
     fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() {
1426
         let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default()));
1627
         let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default()));