@@ -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 { |