gardesk/garcard / 9d3a589

Browse files

Add diagnostics command and remediation hints

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9d3a5897e691a1c3bd06d71cd49b8d141bd75830
Parents
202a73b
Tree
f26a15c

4 changed files

StatusFile+-
M garcard-ipc/src/lib.rs 9 0
M garcard/src/agent.rs 167 22
M garcard/src/daemon.rs 119 2
M garcardctl/src/main.rs 2 0
garcard-ipc/src/lib.rsmodified
@@ -19,6 +19,7 @@ pub const SOCKET_BASENAME: &str = "garcard.sock";
1919
 pub enum Command {
2020
     Ping,
2121
     Status,
22
+    Diagnose,
2223
     Version,
2324
     AuthSummary,
2425
     TempList,
@@ -161,6 +162,14 @@ mod tests {
161162
         assert!(matches!(decoded, Command::AuthSummary));
162163
     }
163164
 
165
+    #[test]
166
+    fn command_round_trip_diagnose() {
167
+        let cmd = Command::Diagnose;
168
+        let encoded = serde_json::to_string(&cmd).expect("encode command");
169
+        let decoded: Command = serde_json::from_str(&encoded).expect("decode command");
170
+        assert!(matches!(decoded, Command::Diagnose));
171
+    }
172
+
164173
     #[test]
165174
     fn socket_path_falls_back_to_tmp() {
166175
         let path = socket_path_from_runtime_dir(None);
garcard/src/agent.rsmodified
@@ -66,6 +66,32 @@ pub struct TemporaryAuthorizationRecord {
6666
     pub expires_at_unix: u64,
6767
     pub expires_in_secs: u64,
6868
 }
69
+
70
+#[derive(Debug, Clone, serde::Serialize)]
71
+pub struct SubjectResolution {
72
+    pub kind: String,
73
+    #[serde(skip_serializing_if = "Option::is_none")]
74
+    pub session_id: Option<String>,
75
+    #[serde(skip_serializing_if = "Option::is_none")]
76
+    pub pid: Option<u32>,
77
+    #[serde(skip_serializing_if = "Option::is_none")]
78
+    pub uid: Option<u32>,
79
+    #[serde(skip_serializing_if = "Option::is_none")]
80
+    pub start_time_ticks: Option<u64>,
81
+    pub has_start_time: bool,
82
+}
83
+
84
+#[derive(Debug, Clone, serde::Serialize)]
85
+pub struct AuthorityDiagnostics {
86
+    pub authority_connected: bool,
87
+    #[serde(skip_serializing_if = "Option::is_none")]
88
+    pub authority_error: Option<String>,
89
+    pub subject: SubjectResolution,
90
+    #[serde(skip_serializing_if = "Option::is_none")]
91
+    pub temporary_authorization_count: Option<usize>,
92
+    #[serde(skip_serializing_if = "Option::is_none")]
93
+    pub temporary_authorization_error: Option<String>,
94
+}
6995
 const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3;
7096
 const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3;
7197
 const DEFAULT_RETENTION_SELECTION_ATTEMPTS: usize = 3;
@@ -672,28 +698,7 @@ fn helper_outcome_label(outcome: HelperOutcome) -> &'static str {
672698
 pub fn enumerate_temporary_authorizations() -> Result<Vec<TemporaryAuthorizationRecord>> {
673699
     let connection = Connection::system().context("failed to connect to system bus")?;
674700
     let subject = build_subject();
675
-    let proxy = PolkitAgent::proxy(&connection)?;
676
-    let authorizations: Vec<TemporaryAuthorization> =
677
-        proxy.call("EnumerateTemporaryAuthorizations", &subject)?;
678
-    let now_unix = SystemTime::now()
679
-        .duration_since(UNIX_EPOCH)
680
-        .map(|duration| duration.as_secs())
681
-        .unwrap_or(0);
682
-
683
-    let mut entries = Vec::with_capacity(authorizations.len());
684
-    for (authorization_id, action_id, _subject, obtained_at_unix, expires_at_unix) in authorizations
685
-    {
686
-        let expires_in_secs = expires_at_unix.saturating_sub(now_unix);
687
-        entries.push(TemporaryAuthorizationRecord {
688
-            authorization_id,
689
-            action_id,
690
-            obtained_at_unix,
691
-            expires_at_unix,
692
-            expires_in_secs,
693
-        });
694
-    }
695
-    entries.sort_by(|left, right| left.action_id.cmp(&right.action_id));
696
-    Ok(entries)
701
+    enumerate_temporary_authorizations_for_subject(&connection, &subject)
697702
 }
698703
 
699704
 pub fn revoke_temporary_authorization_by_id(authorization_id: &str) -> Result<()> {
@@ -739,6 +744,101 @@ fn revoke_temporary_authorizations_for_action(action_id: &str) -> Result<usize>
739744
     Ok(revoked)
740745
 }
741746
 
747
+pub fn current_subject_resolution() -> SubjectResolution {
748
+    if let Some(session_id) = current_session_id() {
749
+        return SubjectResolution {
750
+            kind: "unix-session".to_string(),
751
+            session_id: Some(session_id),
752
+            pid: None,
753
+            uid: None,
754
+            start_time_ticks: None,
755
+            has_start_time: false,
756
+        };
757
+    }
758
+
759
+    let start_time_ticks = process_start_time_ticks();
760
+    SubjectResolution {
761
+        kind: "unix-process".to_string(),
762
+        session_id: None,
763
+        pid: Some(std::process::id()),
764
+        uid: Some(nix::unistd::geteuid().as_raw()),
765
+        start_time_ticks,
766
+        has_start_time: start_time_ticks.is_some(),
767
+    }
768
+}
769
+
770
+pub fn collect_authority_diagnostics() -> AuthorityDiagnostics {
771
+    let subject = current_subject_resolution();
772
+    let connection = match Connection::system() {
773
+        Ok(connection) => connection,
774
+        Err(err) => {
775
+            return AuthorityDiagnostics {
776
+                authority_connected: false,
777
+                authority_error: Some(format!("failed to connect to system bus: {}", err)),
778
+                subject,
779
+                temporary_authorization_count: None,
780
+                temporary_authorization_error: None,
781
+            };
782
+        }
783
+    };
784
+
785
+    if let Err(err) = PolkitAgent::ping_authority(&connection) {
786
+        return AuthorityDiagnostics {
787
+            authority_connected: false,
788
+            authority_error: Some(format!("polkit authority ping failed: {}", err)),
789
+            subject,
790
+            temporary_authorization_count: None,
791
+            temporary_authorization_error: None,
792
+        };
793
+    }
794
+
795
+    let polkit_subject = subject_to_polkit_subject(&subject);
796
+    match enumerate_temporary_authorizations_for_subject(&connection, &polkit_subject) {
797
+        Ok(authorizations) => AuthorityDiagnostics {
798
+            authority_connected: true,
799
+            authority_error: None,
800
+            subject,
801
+            temporary_authorization_count: Some(authorizations.len()),
802
+            temporary_authorization_error: None,
803
+        },
804
+        Err(err) => AuthorityDiagnostics {
805
+            authority_connected: true,
806
+            authority_error: None,
807
+            subject,
808
+            temporary_authorization_count: None,
809
+            temporary_authorization_error: Some(err.to_string()),
810
+        },
811
+    }
812
+}
813
+
814
+fn enumerate_temporary_authorizations_for_subject(
815
+    connection: &Connection,
816
+    subject: &Subject,
817
+) -> Result<Vec<TemporaryAuthorizationRecord>> {
818
+    let proxy = PolkitAgent::proxy(connection)?;
819
+    let authorizations: Vec<TemporaryAuthorization> =
820
+        proxy.call("EnumerateTemporaryAuthorizations", subject)?;
821
+    let now_unix = SystemTime::now()
822
+        .duration_since(UNIX_EPOCH)
823
+        .map(|duration| duration.as_secs())
824
+        .unwrap_or(0);
825
+
826
+    let mut entries = Vec::with_capacity(authorizations.len());
827
+    for (authorization_id, action_id, _subject, obtained_at_unix, expires_at_unix) in authorizations
828
+    {
829
+        let expires_in_secs = expires_at_unix.saturating_sub(now_unix);
830
+        entries.push(TemporaryAuthorizationRecord {
831
+            authorization_id,
832
+            action_id,
833
+            obtained_at_unix,
834
+            expires_at_unix,
835
+            expires_in_secs,
836
+        });
837
+    }
838
+    entries.sort_by(|left, right| left.action_id.cmp(&right.action_id));
839
+    Ok(entries)
840
+}
841
+
742842
 fn render_prompt_context(request: &ActiveRequest) -> String {
743843
     let mut lines = Vec::new();
744844
     let message = request.message.trim();
@@ -1506,6 +1606,14 @@ fn build_subject() -> Subject {
15061606
     subject_from_session_id(current_session_id())
15071607
 }
15081608
 
1609
+fn subject_to_polkit_subject(subject: &SubjectResolution) -> Subject {
1610
+    if subject.kind == "unix-session" {
1611
+        return subject_from_session_id(subject.session_id.clone());
1612
+    }
1613
+
1614
+    build_unix_process_subject()
1615
+}
1616
+
15091617
 fn subject_from_session_id(session_id: Option<String>) -> Subject {
15101618
     if let Some(session_id) = session_id {
15111619
         let mut details = HashMap::new();
@@ -1693,6 +1801,43 @@ mod tests {
16931801
         assert!(subject.1.contains_key("start-time"));
16941802
     }
16951803
 
1804
+    #[test]
1805
+    fn subject_to_polkit_subject_uses_session_resolution() {
1806
+        let resolution = SubjectResolution {
1807
+            kind: "unix-session".to_string(),
1808
+            session_id: Some("42".to_string()),
1809
+            pid: None,
1810
+            uid: None,
1811
+            start_time_ticks: None,
1812
+            has_start_time: false,
1813
+        };
1814
+
1815
+        let subject = subject_to_polkit_subject(&resolution);
1816
+        assert_eq!(subject.0.as_str(), "unix-session");
1817
+        let session_id = subject
1818
+            .1
1819
+            .get("session-id")
1820
+            .and_then(|value| <&str>::try_from(value).ok());
1821
+        assert_eq!(session_id, Some("42"));
1822
+    }
1823
+
1824
+    #[test]
1825
+    fn subject_to_polkit_subject_falls_back_to_unix_process_resolution() {
1826
+        let resolution = SubjectResolution {
1827
+            kind: "unix-process".to_string(),
1828
+            session_id: None,
1829
+            pid: Some(std::process::id()),
1830
+            uid: Some(nix::unistd::geteuid().as_raw()),
1831
+            start_time_ticks: process_start_time_ticks(),
1832
+            has_start_time: true,
1833
+        };
1834
+
1835
+        let subject = subject_to_polkit_subject(&resolution);
1836
+        assert_eq!(subject.0.as_str(), "unix-process");
1837
+        assert!(subject.1.contains_key("pid"));
1838
+        assert!(subject.1.contains_key("uid"));
1839
+    }
1840
+
16961841
     #[test]
16971842
     fn invalid_object_path_error_message_mentions_path() {
16981843
         let err = match PolkitAgent::new(
garcard/src/daemon.rsmodified
@@ -1,7 +1,7 @@
11
 use crate::agent::{
22
     AuthAgentBackend, PolkitAgent, PolkitBackendConfig, StubPolkitAgent,
3
-    enumerate_temporary_authorizations, revoke_all_temporary_authorizations,
4
-    revoke_temporary_authorization_by_id,
3
+    collect_authority_diagnostics, enumerate_temporary_authorizations,
4
+    revoke_all_temporary_authorizations, revoke_temporary_authorization_by_id,
55
 };
66
 use crate::config::{AgentBackendMode, Config};
77
 use crate::state::{AuthState, RuntimeState};
@@ -288,6 +288,20 @@ fn dispatch(
288288
     match command {
289289
         Command::Ping => Response::ok_with_data(json!({ "pong": true })),
290290
         Command::Status => Response::ok_with_data(state.status()),
291
+        Command::Diagnose => {
292
+            let diagnostics = collect_authority_diagnostics();
293
+            let auth_summary = state.auth_summary();
294
+            let hints = diagnostics_hints(&diagnostics, auth_summary.last_outcome.as_deref());
295
+            Response::ok_with_data(json!({
296
+                "authority_connected": diagnostics.authority_connected,
297
+                "authority_error": diagnostics.authority_error,
298
+                "subject": diagnostics.subject,
299
+                "temporary_authorization_count": diagnostics.temporary_authorization_count,
300
+                "temporary_authorization_error": diagnostics.temporary_authorization_error,
301
+                "last_outcome": auth_summary.last_outcome,
302
+                "hints": hints,
303
+            }))
304
+        }
291305
         Command::Version => Response::ok_with_data(state.version()),
292306
         Command::AuthSummary => Response::ok_with_data(state.auth_summary()),
293307
         Command::TempList => match enumerate_temporary_authorizations() {
@@ -328,6 +342,56 @@ fn dispatch(
328342
     }
329343
 }
330344
 
345
+fn diagnostics_hints(
346
+    diagnostics: &crate::agent::AuthorityDiagnostics,
347
+    last_outcome: Option<&str>,
348
+) -> Vec<String> {
349
+    let mut hints = Vec::new();
350
+
351
+    if !diagnostics.authority_connected {
352
+        hints.push(
353
+            "No agent path: verify garcard daemon is running and reachable with `garcardctl ping`."
354
+                .to_string(),
355
+        );
356
+        hints.push(
357
+            "If polkit was restarted, relaunch garcard (`garcardctl quit`, then start daemon again)."
358
+                .to_string(),
359
+        );
360
+    }
361
+
362
+    if diagnostics.subject.kind != "unix-session" {
363
+        hints.push(
364
+            "Subject is `unix-process` fallback; set a valid `XDG_SESSION_ID` and restart daemon for session-scoped auth."
365
+                .to_string(),
366
+        );
367
+    }
368
+
369
+    if diagnostics
370
+        .temporary_authorization_error
371
+        .as_ref()
372
+        .is_some_and(|error| !error.trim().is_empty())
373
+    {
374
+        hints.push(
375
+            "Temporary-authorization inspection failed; verify authority permissions and dbus connectivity."
376
+                .to_string(),
377
+        );
378
+    }
379
+
380
+    if matches!(last_outcome, Some("failure")) {
381
+        hints.push(
382
+            "Denied flow observed; verify account policy membership (admin/wheel) and retry with `pkcheck --allow-user-interaction ...`."
383
+                .to_string(),
384
+        );
385
+    } else {
386
+        hints.push(
387
+            "For denied-flow debugging, run daemon with `RUST_LOG=garcard=debug` and trigger via `pkcheck --allow-user-interaction ...`."
388
+                .to_string(),
389
+        );
390
+    }
391
+
392
+    hints
393
+}
394
+
331395
 #[cfg(test)]
332396
 mod tests {
333397
     use super::*;
@@ -361,6 +425,59 @@ mod tests {
361425
         assert!(shutdown_rx.try_recv().is_ok());
362426
     }
363427
 
428
+    #[test]
429
+    fn diagnostics_hints_cover_no_agent_and_denied_flows() {
430
+        let diagnostics = crate::agent::AuthorityDiagnostics {
431
+            authority_connected: false,
432
+            authority_error: Some("failed to connect to system bus".to_string()),
433
+            subject: crate::agent::SubjectResolution {
434
+                kind: "unix-process".to_string(),
435
+                session_id: None,
436
+                pid: Some(1000),
437
+                uid: Some(1000),
438
+                start_time_ticks: None,
439
+                has_start_time: false,
440
+            },
441
+            temporary_authorization_count: None,
442
+            temporary_authorization_error: Some("dbus unavailable".to_string()),
443
+        };
444
+
445
+        let hints = diagnostics_hints(&diagnostics, Some("failure"));
446
+        assert!(hints.iter().any(|hint| hint.contains("No agent path")));
447
+        assert!(
448
+            hints
449
+                .iter()
450
+                .any(|hint| hint.contains("Denied flow observed"))
451
+        );
452
+        assert!(hints.iter().any(|hint| hint.contains("unix-process")));
453
+    }
454
+
455
+    #[test]
456
+    fn diagnostics_hints_include_debug_guidance_without_failure_outcome() {
457
+        let diagnostics = crate::agent::AuthorityDiagnostics {
458
+            authority_connected: true,
459
+            authority_error: None,
460
+            subject: crate::agent::SubjectResolution {
461
+                kind: "unix-session".to_string(),
462
+                session_id: Some("7".to_string()),
463
+                pid: None,
464
+                uid: None,
465
+                start_time_ticks: None,
466
+                has_start_time: false,
467
+            },
468
+            temporary_authorization_count: Some(1),
469
+            temporary_authorization_error: None,
470
+        };
471
+
472
+        let hints = diagnostics_hints(&diagnostics, Some("success"));
473
+        assert!(
474
+            hints
475
+                .iter()
476
+                .any(|hint| hint.contains("denied-flow debugging"))
477
+        );
478
+        assert!(!hints.iter().any(|hint| hint.contains("No agent path")));
479
+    }
480
+
364481
     #[test]
365482
     fn auto_backend_falls_back_to_stub_for_bad_object_path() {
366483
         let config = Config {
garcardctl/src/main.rsmodified
@@ -15,6 +15,7 @@ struct Cli {
1515
 enum Commands {
1616
     Ping,
1717
     Status,
18
+    Diagnose,
1819
     Version,
1920
     AuthSummary,
2021
     TempList,
@@ -48,6 +49,7 @@ fn to_protocol_command(command: Commands) -> Command {
4849
     match command {
4950
         Commands::Ping => Command::Ping,
5051
         Commands::Status => Command::Status,
52
+        Commands::Diagnose => Command::Diagnose,
5153
         Commands::Version => Command::Version,
5254
         Commands::AuthSummary => Command::AuthSummary,
5355
         Commands::TempList => Command::TempList,