@@ -55,6 +55,7 @@ pub struct PolkitBackendConfig { |
| 55 | 55 | |
| 56 | 56 | type Subject = (String, HashMap<String, OwnedValue>); |
| 57 | 57 | type Details = HashMap<String, String>; |
| 58 | +type TemporaryAuthorization = (String, String, Subject, u64, u64); |
| 58 | 59 | const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3; |
| 59 | 60 | const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3; |
| 60 | 61 | const DEFAULT_RETENTION_SELECTION_ATTEMPTS: usize = 3; |
@@ -394,11 +395,15 @@ impl PolkitRuntime { |
| 394 | 395 | let max_attempts = auth_max_attempts(); |
| 395 | 396 | let username = match select_identity_for_request(request, prompts) { |
| 396 | 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 | 402 | let retention = match select_retention_for_request(request, prompts) { |
| 400 | 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 | 408 | tracing::info!( |
| 404 | 409 | action_id = %request.action_id, |
@@ -412,7 +417,7 @@ impl PolkitRuntime { |
| 412 | 417 | action_id = %request.action_id, |
| 413 | 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 | 422 | tracing::info!( |
| 418 | 423 | context = %prompt_context, |
@@ -432,7 +437,11 @@ impl PolkitRuntime { |
| 432 | 437 | action_id = %request.action_id, |
| 433 | 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 | 446 | Ok(HelperOutcome::Denied) if attempt < max_attempts => { |
| 438 | 447 | prompts.on_retry(); |
@@ -445,7 +454,9 @@ impl PolkitRuntime { |
| 445 | 454 | ); |
| 446 | 455 | continue; |
| 447 | 456 | } |
| 448 | | - Ok(outcome) => return outcome, |
| 457 | + Ok(outcome) => { |
| 458 | + return self.finalize_auth_attempt(request, outcome, Some(retention)); |
| 459 | + } |
| 449 | 460 | Err(err) => { |
| 450 | 461 | tracing::warn!( |
| 451 | 462 | action_id = %request.action_id, |
@@ -459,12 +470,71 @@ impl PolkitRuntime { |
| 459 | 470 | self.auth_state.set_phase(AuthPhase::PendingPrompt); |
| 460 | 471 | continue; |
| 461 | 472 | } |
| 462 | | - return HelperOutcome::Denied; |
| 473 | + return self.finalize_auth_attempt( |
| 474 | + request, |
| 475 | + HelperOutcome::Denied, |
| 476 | + Some(retention), |
| 477 | + ); |
| 478 | + } |
| 479 | + } |
| 480 | + } |
| 481 | + |
| 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 |
| 463 | 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; |
| 464 | 515 | } |
| 516 | + if request.retention_options.len() <= 1 { |
| 517 | + return false; |
| 465 | 518 | } |
| 466 | 519 | |
| 467 | | - HelperOutcome::Denied |
| 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 | 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 | 678 | fn render_prompt_context(request: &ActiveRequest) -> String { |
| 580 | 679 | let mut lines = Vec::new(); |
| 581 | 680 | let message = request.message.trim(); |
@@ -1894,6 +1993,14 @@ mod tests { |
| 1894 | 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 | 2004 | #[test] |
| 1898 | 2005 | fn select_retention_for_request_uses_prompted_choice() { |
| 1899 | 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 | 2053 | #[test] |
| 1920 | 2054 | fn select_identity_for_request_uses_prompted_choice() { |
| 1921 | 2055 | let request = ActiveRequest { |