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 {
8686
     pub state: String,
8787
     pub active_requests: usize,
8888
     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>,
8997
 }
9098
 
9199
 /// Resolve the daemon socket path from `XDG_RUNTIME_DIR` with `/tmp` fallback.
garcard/src/agent.rsmodified
@@ -55,6 +55,7 @@ pub struct PolkitBackendConfig {
5555
 
5656
 type Subject = (String, HashMap<String, OwnedValue>);
5757
 type Details = HashMap<String, String>;
58
+type TemporaryAuthorization = (String, String, Subject, u64, u64);
5859
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
5960
 const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
6061
 const DEFAULT_RETENTION_SELECTION_ATTEMPTS: usize = 3;
@@ -394,11 +395,15 @@ impl PolkitRuntime {
394395
         let max_attempts = auth_max_attempts();
395396
         let username = match select_identity_for_request(request, prompts) {
396397
             IdentitySelection::Selected(selected) => selected,
397
-            IdentitySelection::Terminal(outcome) => return outcome,
398
+            IdentitySelection::Terminal(outcome) => {
399
+                return self.finalize_auth_attempt(request, outcome, None);
400
+            }
398401
         };
399402
         let retention = match select_retention_for_request(request, prompts) {
400403
             RetentionSelection::Selected(selected) => selected,
401
-            RetentionSelection::Terminal(outcome) => return outcome,
404
+            RetentionSelection::Terminal(outcome) => {
405
+                return self.finalize_auth_attempt(request, outcome, None);
406
+            }
402407
         };
403408
         tracing::info!(
404409
             action_id = %request.action_id,
@@ -412,7 +417,7 @@ impl PolkitRuntime {
412417
                     action_id = %request.action_id,
413418
                     "Authentication request canceled before helper attempt"
414419
                 );
415
-                return HelperOutcome::Canceled;
420
+                return self.finalize_auth_attempt(request, HelperOutcome::Canceled, Some(retention));
416421
             }
417422
             tracing::info!(
418423
                 context = %prompt_context,
@@ -432,7 +437,11 @@ impl PolkitRuntime {
432437
                         action_id = %request.action_id,
433438
                         "Authentication request canceled during helper attempt"
434439
                     );
435
-                    return HelperOutcome::Canceled;
440
+                    return self.finalize_auth_attempt(
441
+                        request,
442
+                        HelperOutcome::Canceled,
443
+                        Some(retention),
444
+                    );
436445
                 }
437446
                 Ok(HelperOutcome::Denied) if attempt < max_attempts => {
438447
                     prompts.on_retry();
@@ -445,7 +454,9 @@ impl PolkitRuntime {
445454
                     );
446455
                     continue;
447456
                 }
448
-                Ok(outcome) => return outcome,
457
+                Ok(outcome) => {
458
+                    return self.finalize_auth_attempt(request, outcome, Some(retention));
459
+                }
449460
                 Err(err) => {
450461
                     tracing::warn!(
451462
                         action_id = %request.action_id,
@@ -459,12 +470,71 @@ impl PolkitRuntime {
459470
                         self.auth_state.set_phase(AuthPhase::PendingPrompt);
460471
                         continue;
461472
                     }
462
-                    return HelperOutcome::Denied;
473
+                    return self.finalize_auth_attempt(
474
+                        request,
475
+                        HelperOutcome::Denied,
476
+                        Some(retention),
477
+                    );
463478
                 }
464479
             }
465480
         }
466481
 
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
+        }
468538
     }
469539
 
470540
     fn complete_request(&self, cookie: &str, outcome: HelperOutcome) {
@@ -576,6 +646,35 @@ impl PolkitRuntime {
576646
     }
577647
 }
578648
 
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
+
579678
 fn render_prompt_context(request: &ActiveRequest) -> String {
580679
     let mut lines = Vec::new();
581680
     let message = request.message.trim();
@@ -1894,6 +1993,14 @@ mod tests {
18941993
         assert_eq!(parse_retention_selection("unknown", &options), None);
18951994
     }
18961995
 
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
+
18972004
     #[test]
18982005
     fn select_retention_for_request_uses_prompted_choice() {
18992006
         let request = ActiveRequest {
@@ -1916,6 +2023,33 @@ mod tests {
19162023
         ));
19172024
     }
19182025
 
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
+
19192053
     #[test]
19202054
     fn select_identity_for_request_uses_prompted_choice() {
19212055
         let request = ActiveRequest {
garcard/src/state.rsmodified
@@ -67,6 +67,7 @@ pub struct AuthState {
6767
     current_phase: RwLock<AuthPhase>,
6868
     active_requests: AtomicUsize,
6969
     queued_requests: AtomicUsize,
70
+    last_decision: RwLock<Option<AuthDecision>>,
7071
 }
7172
 
7273
 impl Default for AuthState {
@@ -75,10 +76,19 @@ impl Default for AuthState {
7576
             current_phase: RwLock::new(AuthPhase::Idle),
7677
             active_requests: AtomicUsize::new(0),
7778
             queued_requests: AtomicUsize::new(0),
79
+            last_decision: RwLock::new(None),
7880
         }
7981
     }
8082
 }
8183
 
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
+
8292
 impl AuthState {
8393
     pub fn phase(&self) -> AuthPhase {
8494
         self.current_phase
@@ -120,10 +130,51 @@ impl AuthState {
120130
     }
121131
 
122132
     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));
123147
         AuthSummary {
124148
             state: self.phase().to_string(),
125149
             active_requests: self.active_requests.load(Ordering::Relaxed),
126150
             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;
127178
         }
128179
     }
129180
 }
@@ -260,6 +311,7 @@ impl RuntimeState {
260311
         auth.set_phase(AuthPhase::Idle);
261312
         auth.set_active_requests(0);
262313
         auth.set_queued_requests(0);
314
+        auth.clear_last_decision();
263315
         Self {
264316
             started_at: Instant::now(),
265317
             pid: std::process::id(),
@@ -310,6 +362,10 @@ mod tests {
310362
         assert_eq!(summary.state, "idle");
311363
         assert_eq!(summary.active_requests, 0);
312364
         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());
313369
     }
314370
 
315371
     #[test]
@@ -325,6 +381,26 @@ mod tests {
325381
         assert_eq!(summary.queued_requests, 3);
326382
     }
327383
 
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
+
328404
     #[test]
329405
     fn auth_phase_transition_rules_are_enforced() {
330406
         let state = AuthState::default();