gardesk/garcard / 17012a9

Browse files

Map retention choice to auth lifecycle

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
17012a95c9741152e8d2cd78756d88c914bf58e0
Parents
b84d6dc
Tree
3396c02

3 changed files

StatusFile+-
M garcard-ipc/src/lib.rs 8 0
M garcard/src/agent.rs 141 7
M garcard/src/state.rs 76 0
garcard-ipc/src/lib.rsmodified
@@ -86,6 +86,14 @@ pub struct AuthSummary {
86
     pub state: String,
86
     pub state: String,
87
     pub active_requests: usize,
87
     pub active_requests: usize,
88
     pub queued_requests: usize,
88
     pub queued_requests: usize,
89
+    #[serde(skip_serializing_if = "Option::is_none")]
90
+    pub last_action_id: Option<String>,
91
+    #[serde(skip_serializing_if = "Option::is_none")]
92
+    pub last_outcome: Option<String>,
93
+    #[serde(skip_serializing_if = "Option::is_none")]
94
+    pub last_retention_policy: Option<String>,
95
+    #[serde(skip_serializing_if = "Option::is_none")]
96
+    pub last_retention_enforced: Option<bool>,
89
 }
97
 }
90
 
98
 
91
 /// Resolve the daemon socket path from `XDG_RUNTIME_DIR` with `/tmp` fallback.
99
 /// Resolve the daemon socket path from `XDG_RUNTIME_DIR` with `/tmp` fallback.
garcard/src/agent.rsmodified
@@ -55,6 +55,7 @@ pub struct PolkitBackendConfig {
55
 
55
 
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
+type TemporaryAuthorization = (String, String, Subject, u64, u64);
58
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
59
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
59
 const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
60
 const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
60
 const DEFAULT_RETENTION_SELECTION_ATTEMPTS: usize = 3;
61
 const DEFAULT_RETENTION_SELECTION_ATTEMPTS: usize = 3;
@@ -394,11 +395,15 @@ impl PolkitRuntime {
394
         let max_attempts = auth_max_attempts();
395
         let max_attempts = auth_max_attempts();
395
         let username = match select_identity_for_request(request, prompts) {
396
         let username = match select_identity_for_request(request, prompts) {
396
             IdentitySelection::Selected(selected) => selected,
397
             IdentitySelection::Selected(selected) => selected,
397
-            IdentitySelection::Terminal(outcome) => return outcome,
398
+            IdentitySelection::Terminal(outcome) => {
399
+                return self.finalize_auth_attempt(request, outcome, None);
400
+            }
398
         };
401
         };
399
         let retention = match select_retention_for_request(request, prompts) {
402
         let retention = match select_retention_for_request(request, prompts) {
400
             RetentionSelection::Selected(selected) => selected,
403
             RetentionSelection::Selected(selected) => selected,
401
-            RetentionSelection::Terminal(outcome) => return outcome,
404
+            RetentionSelection::Terminal(outcome) => {
405
+                return self.finalize_auth_attempt(request, outcome, None);
406
+            }
402
         };
407
         };
403
         tracing::info!(
408
         tracing::info!(
404
             action_id = %request.action_id,
409
             action_id = %request.action_id,
@@ -412,7 +417,7 @@ impl PolkitRuntime {
412
                     action_id = %request.action_id,
417
                     action_id = %request.action_id,
413
                     "Authentication request canceled before helper attempt"
418
                     "Authentication request canceled before helper attempt"
414
                 );
419
                 );
415
-                return HelperOutcome::Canceled;
420
+                return self.finalize_auth_attempt(request, HelperOutcome::Canceled, Some(retention));
416
             }
421
             }
417
             tracing::info!(
422
             tracing::info!(
418
                 context = %prompt_context,
423
                 context = %prompt_context,
@@ -432,7 +437,11 @@ impl PolkitRuntime {
432
                         action_id = %request.action_id,
437
                         action_id = %request.action_id,
433
                         "Authentication request canceled during helper attempt"
438
                         "Authentication request canceled during helper attempt"
434
                     );
439
                     );
435
-                    return HelperOutcome::Canceled;
440
+                    return self.finalize_auth_attempt(
441
+                        request,
442
+                        HelperOutcome::Canceled,
443
+                        Some(retention),
444
+                    );
436
                 }
445
                 }
437
                 Ok(HelperOutcome::Denied) if attempt < max_attempts => {
446
                 Ok(HelperOutcome::Denied) if attempt < max_attempts => {
438
                     prompts.on_retry();
447
                     prompts.on_retry();
@@ -445,7 +454,9 @@ impl PolkitRuntime {
445
                     );
454
                     );
446
                     continue;
455
                     continue;
447
                 }
456
                 }
448
-                Ok(outcome) => return outcome,
457
+                Ok(outcome) => {
458
+                    return self.finalize_auth_attempt(request, outcome, Some(retention));
459
+                }
449
                 Err(err) => {
460
                 Err(err) => {
450
                     tracing::warn!(
461
                     tracing::warn!(
451
                         action_id = %request.action_id,
462
                         action_id = %request.action_id,
@@ -459,12 +470,71 @@ impl PolkitRuntime {
459
                         self.auth_state.set_phase(AuthPhase::PendingPrompt);
470
                         self.auth_state.set_phase(AuthPhase::PendingPrompt);
460
                         continue;
471
                         continue;
461
                     }
472
                     }
462
-                    return HelperOutcome::Denied;
473
+                    return self.finalize_auth_attempt(
474
+                        request,
475
+                        HelperOutcome::Denied,
476
+                        Some(retention),
477
+                    );
463
                 }
478
                 }
464
             }
479
             }
465
         }
480
         }
466
 
481
 
467
-        HelperOutcome::Denied
482
+        self.finalize_auth_attempt(request, HelperOutcome::Denied, Some(retention))
483
+    }
484
+
485
+    fn finalize_auth_attempt(
486
+        &self,
487
+        request: &ActiveRequest,
488
+        outcome: HelperOutcome,
489
+        retention: Option<RetentionPolicy>,
490
+    ) -> HelperOutcome {
491
+        let retention_enforced = self.enforce_retention_policy(request, outcome, retention);
492
+        self.auth_state.set_last_decision(
493
+            request.action_id.clone(),
494
+            helper_outcome_label(outcome),
495
+            retention.map(|policy| policy.label().to_string()),
496
+            retention_enforced,
497
+        );
498
+        outcome
499
+    }
500
+
501
+    fn enforce_retention_policy(
502
+        &self,
503
+        request: &ActiveRequest,
504
+        outcome: HelperOutcome,
505
+        retention: Option<RetentionPolicy>,
506
+    ) -> bool {
507
+        if outcome != HelperOutcome::Authorized {
508
+            return false;
509
+        }
510
+        let Some(retention) = retention else {
511
+            return false;
512
+        };
513
+        if retention != RetentionPolicy::OneShot {
514
+            return false;
515
+        }
516
+        if request.retention_options.len() <= 1 {
517
+            return false;
518
+        }
519
+
520
+        match revoke_temporary_authorizations_for_action(&request.action_id) {
521
+            Ok(revoked) => {
522
+                tracing::info!(
523
+                    action_id = %request.action_id,
524
+                    revoked_count = revoked,
525
+                    "Applied one-shot retention policy by revoking temporary authorizations"
526
+                );
527
+                true
528
+            }
529
+            Err(err) => {
530
+                tracing::warn!(
531
+                    action_id = %request.action_id,
532
+                    error = %err,
533
+                    "Failed to enforce one-shot retention policy"
534
+                );
535
+                false
536
+            }
537
+        }
468
     }
538
     }
469
 
539
 
470
     fn complete_request(&self, cookie: &str, outcome: HelperOutcome) {
540
     fn complete_request(&self, cookie: &str, outcome: HelperOutcome) {
@@ -576,6 +646,35 @@ impl PolkitRuntime {
576
     }
646
     }
577
 }
647
 }
578
 
648
 
649
+fn helper_outcome_label(outcome: HelperOutcome) -> &'static str {
650
+    match outcome {
651
+        HelperOutcome::Authorized => "success",
652
+        HelperOutcome::Denied => "failure",
653
+        HelperOutcome::Canceled => "canceled",
654
+        HelperOutcome::Timeout => "timeout",
655
+    }
656
+}
657
+
658
+fn revoke_temporary_authorizations_for_action(action_id: &str) -> Result<usize> {
659
+    let connection = Connection::system().context("failed to connect to system bus")?;
660
+    let subject = build_subject();
661
+    let proxy = PolkitAgent::proxy(&connection)?;
662
+    let authorizations: Vec<TemporaryAuthorization> =
663
+        proxy.call("EnumerateTemporaryAuthorizations", &subject)?;
664
+
665
+    let mut revoked = 0_usize;
666
+    for (authorization_id, auth_action_id, _subject, _obtained, _expires) in authorizations {
667
+        if auth_action_id != action_id {
668
+            continue;
669
+        }
670
+
671
+        let _: () = proxy.call("RevokeTemporaryAuthorizationById", &authorization_id)?;
672
+        revoked += 1;
673
+    }
674
+
675
+    Ok(revoked)
676
+}
677
+
579
 fn render_prompt_context(request: &ActiveRequest) -> String {
678
 fn render_prompt_context(request: &ActiveRequest) -> String {
580
     let mut lines = Vec::new();
679
     let mut lines = Vec::new();
581
     let message = request.message.trim();
680
     let message = request.message.trim();
@@ -1894,6 +1993,14 @@ mod tests {
1894
         assert_eq!(parse_retention_selection("unknown", &options), None);
1993
         assert_eq!(parse_retention_selection("unknown", &options), None);
1895
     }
1994
     }
1896
 
1995
 
1996
+    #[test]
1997
+    fn helper_outcome_label_maps_outcomes() {
1998
+        assert_eq!(helper_outcome_label(HelperOutcome::Authorized), "success");
1999
+        assert_eq!(helper_outcome_label(HelperOutcome::Denied), "failure");
2000
+        assert_eq!(helper_outcome_label(HelperOutcome::Canceled), "canceled");
2001
+        assert_eq!(helper_outcome_label(HelperOutcome::Timeout), "timeout");
2002
+    }
2003
+
1897
     #[test]
2004
     #[test]
1898
     fn select_retention_for_request_uses_prompted_choice() {
2005
     fn select_retention_for_request_uses_prompted_choice() {
1899
         let request = ActiveRequest {
2006
         let request = ActiveRequest {
@@ -1916,6 +2023,33 @@ mod tests {
1916
         ));
2023
         ));
1917
     }
2024
     }
1918
 
2025
 
2026
+    #[test]
2027
+    fn finalize_auth_attempt_records_retention_in_auth_summary() {
2028
+        let auth_state = Arc::new(AuthState::default());
2029
+        let runtime = PolkitRuntime::new_without_worker(Arc::clone(&auth_state));
2030
+        let request = ActiveRequest {
2031
+            action_id: "org.gardesk.test".to_string(),
2032
+            message: "Authenticate".to_string(),
2033
+            icon_name: "dialog-password".to_string(),
2034
+            detail_count: 0,
2035
+            details: HashMap::new(),
2036
+            cookie: "cookie-finalize".to_string(),
2037
+            username: "operator".to_string(),
2038
+            identity_options: vec!["operator".to_string()],
2039
+            retention_options: vec![RetentionPolicy::OneShot],
2040
+        };
2041
+
2042
+        let outcome =
2043
+            runtime.finalize_auth_attempt(&request, HelperOutcome::Denied, Some(RetentionPolicy::OneShot));
2044
+        assert_eq!(outcome, HelperOutcome::Denied);
2045
+
2046
+        let summary = auth_state.summary();
2047
+        assert_eq!(summary.last_action_id.as_deref(), Some("org.gardesk.test"));
2048
+        assert_eq!(summary.last_outcome.as_deref(), Some("failure"));
2049
+        assert_eq!(summary.last_retention_policy.as_deref(), Some("one-shot"));
2050
+        assert_eq!(summary.last_retention_enforced, Some(false));
2051
+    }
2052
+
1919
     #[test]
2053
     #[test]
1920
     fn select_identity_for_request_uses_prompted_choice() {
2054
     fn select_identity_for_request_uses_prompted_choice() {
1921
         let request = ActiveRequest {
2055
         let request = ActiveRequest {
garcard/src/state.rsmodified
@@ -67,6 +67,7 @@ pub struct AuthState {
67
     current_phase: RwLock<AuthPhase>,
67
     current_phase: RwLock<AuthPhase>,
68
     active_requests: AtomicUsize,
68
     active_requests: AtomicUsize,
69
     queued_requests: AtomicUsize,
69
     queued_requests: AtomicUsize,
70
+    last_decision: RwLock<Option<AuthDecision>>,
70
 }
71
 }
71
 
72
 
72
 impl Default for AuthState {
73
 impl Default for AuthState {
@@ -75,10 +76,19 @@ impl Default for AuthState {
75
             current_phase: RwLock::new(AuthPhase::Idle),
76
             current_phase: RwLock::new(AuthPhase::Idle),
76
             active_requests: AtomicUsize::new(0),
77
             active_requests: AtomicUsize::new(0),
77
             queued_requests: AtomicUsize::new(0),
78
             queued_requests: AtomicUsize::new(0),
79
+            last_decision: RwLock::new(None),
78
         }
80
         }
79
     }
81
     }
80
 }
82
 }
81
 
83
 
84
+#[derive(Debug, Clone)]
85
+struct AuthDecision {
86
+    action_id: String,
87
+    outcome: String,
88
+    retention_policy: Option<String>,
89
+    retention_enforced: bool,
90
+}
91
+
82
 impl AuthState {
92
 impl AuthState {
83
     pub fn phase(&self) -> AuthPhase {
93
     pub fn phase(&self) -> AuthPhase {
84
         self.current_phase
94
         self.current_phase
@@ -120,10 +130,51 @@ impl AuthState {
120
     }
130
     }
121
 
131
 
122
     pub fn summary(&self) -> AuthSummary {
132
     pub fn summary(&self) -> AuthSummary {
133
+        let (last_action_id, last_outcome, last_retention_policy, last_retention_enforced) = self
134
+            .last_decision
135
+            .read()
136
+            .ok()
137
+            .and_then(|decision| decision.clone())
138
+            .map(|decision| {
139
+                (
140
+                    Some(decision.action_id),
141
+                    Some(decision.outcome),
142
+                    decision.retention_policy,
143
+                    Some(decision.retention_enforced),
144
+                )
145
+            })
146
+            .unwrap_or((None, None, None, None));
123
         AuthSummary {
147
         AuthSummary {
124
             state: self.phase().to_string(),
148
             state: self.phase().to_string(),
125
             active_requests: self.active_requests.load(Ordering::Relaxed),
149
             active_requests: self.active_requests.load(Ordering::Relaxed),
126
             queued_requests: self.queued_requests.load(Ordering::Relaxed),
150
             queued_requests: self.queued_requests.load(Ordering::Relaxed),
151
+            last_action_id,
152
+            last_outcome,
153
+            last_retention_policy,
154
+            last_retention_enforced,
155
+        }
156
+    }
157
+
158
+    pub fn set_last_decision(
159
+        &self,
160
+        action_id: impl Into<String>,
161
+        outcome: impl Into<String>,
162
+        retention_policy: Option<String>,
163
+        retention_enforced: bool,
164
+    ) {
165
+        if let Ok(mut decision) = self.last_decision.write() {
166
+            *decision = Some(AuthDecision {
167
+                action_id: action_id.into(),
168
+                outcome: outcome.into(),
169
+                retention_policy,
170
+                retention_enforced,
171
+            });
172
+        }
173
+    }
174
+
175
+    pub fn clear_last_decision(&self) {
176
+        if let Ok(mut decision) = self.last_decision.write() {
177
+            *decision = None;
127
         }
178
         }
128
     }
179
     }
129
 }
180
 }
@@ -260,6 +311,7 @@ impl RuntimeState {
260
         auth.set_phase(AuthPhase::Idle);
311
         auth.set_phase(AuthPhase::Idle);
261
         auth.set_active_requests(0);
312
         auth.set_active_requests(0);
262
         auth.set_queued_requests(0);
313
         auth.set_queued_requests(0);
314
+        auth.clear_last_decision();
263
         Self {
315
         Self {
264
             started_at: Instant::now(),
316
             started_at: Instant::now(),
265
             pid: std::process::id(),
317
             pid: std::process::id(),
@@ -310,6 +362,10 @@ mod tests {
310
         assert_eq!(summary.state, "idle");
362
         assert_eq!(summary.state, "idle");
311
         assert_eq!(summary.active_requests, 0);
363
         assert_eq!(summary.active_requests, 0);
312
         assert_eq!(summary.queued_requests, 0);
364
         assert_eq!(summary.queued_requests, 0);
365
+        assert!(summary.last_action_id.is_none());
366
+        assert!(summary.last_outcome.is_none());
367
+        assert!(summary.last_retention_policy.is_none());
368
+        assert!(summary.last_retention_enforced.is_none());
313
     }
369
     }
314
 
370
 
315
     #[test]
371
     #[test]
@@ -325,6 +381,26 @@ mod tests {
325
         assert_eq!(summary.queued_requests, 3);
381
         assert_eq!(summary.queued_requests, 3);
326
     }
382
     }
327
 
383
 
384
+    #[test]
385
+    fn auth_state_records_last_decision_context() {
386
+        let state = AuthState::default();
387
+        state.set_last_decision(
388
+            "com.gardesk.install",
389
+            "success",
390
+            Some("one-shot".to_string()),
391
+            true,
392
+        );
393
+
394
+        let summary = state.summary();
395
+        assert_eq!(
396
+            summary.last_action_id.as_deref(),
397
+            Some("com.gardesk.install")
398
+        );
399
+        assert_eq!(summary.last_outcome.as_deref(), Some("success"));
400
+        assert_eq!(summary.last_retention_policy.as_deref(), Some("one-shot"));
401
+        assert_eq!(summary.last_retention_enforced, Some(true));
402
+    }
403
+
328
     #[test]
404
     #[test]
329
     fn auth_phase_transition_rules_are_enforced() {
405
     fn auth_phase_transition_rules_are_enforced() {
330
         let state = AuthState::default();
406
         let state = AuthState::default();