Skip to main content

guacamole_client/
user.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::client::GuacamoleClient;
8use crate::error::Result;
9use crate::history::HistoryEntry;
10use crate::patch::PatchOperation;
11use crate::validation::validate_username;
12
13/// A Guacamole user account.
14#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16#[non_exhaustive]
17pub struct User {
18    /// The username.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub username: Option<String>,
21
22    /// The password (write-only, never returned by the server).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub password: Option<String>,
25
26    /// Arbitrary user attributes.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub attributes: Option<HashMap<String, Option<String>>>,
29}
30
31impl fmt::Debug for User {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct("User")
34            .field("username", &self.username)
35            .field("password", &"<redacted>")
36            .field("attributes", &self.attributes)
37            .finish()
38    }
39}
40
41/// Payload for changing a user's password.
42#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44#[non_exhaustive]
45pub struct PasswordChange {
46    /// The current password.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub old_password: Option<String>,
49
50    /// The desired new password.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub new_password: Option<String>,
53}
54
55impl fmt::Debug for PasswordChange {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.debug_struct("PasswordChange")
58            .field("old_password", &"<redacted>")
59            .field("new_password", &"<redacted>")
60            .finish()
61    }
62}
63
64/// Permissions assigned to a user.
65///
66/// Uses `serde_json::Value` for forward-compatibility with new permission types.
67#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69#[non_exhaustive]
70pub struct UserPermissions {
71    /// Per-connection permissions keyed by connection ID.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub connection_permissions: Option<HashMap<String, Vec<String>>>,
74
75    /// Per-connection-group permissions keyed by group ID.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub connection_group_permissions: Option<HashMap<String, Vec<String>>>,
78
79    /// Per-sharing-profile permissions keyed by sharing profile ID.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub sharing_profile_permissions: Option<HashMap<String, Vec<String>>>,
82
83    /// System-level permissions.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub system_permissions: Option<Vec<String>>,
86
87    /// Per-user permissions keyed by username.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub user_permissions: Option<HashMap<String, Vec<String>>>,
90
91    /// Any additional permission fields not covered by the known fields.
92    #[serde(flatten)]
93    pub extra: HashMap<String, Value>,
94}
95
96impl GuacamoleClient {
97    /// Lists all users in the given data source.
98    pub async fn list_users(
99        &self,
100        data_source: Option<&str>,
101    ) -> Result<HashMap<String, User>> {
102        let ds = self.resolve_data_source(data_source)?;
103        let url = self.url(&format!("/api/session/data/{ds}/users"))?;
104        let response = self.http.get(&url).send().await?;
105        Self::parse_response(response, "users").await
106    }
107
108    /// Retrieves a single user by username.
109    pub async fn get_user(
110        &self,
111        data_source: Option<&str>,
112        username: &str,
113    ) -> Result<User> {
114        validate_username(username)?;
115        let ds = self.resolve_data_source(data_source)?;
116        let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
117        let response = self.http.get(&url).send().await?;
118        Self::parse_response(response, &format!("user {username}")).await
119    }
120
121    /// Retrieves the currently authenticated user.
122    pub async fn get_self(
123        &self,
124        data_source: Option<&str>,
125    ) -> Result<User> {
126        let ds = self.resolve_data_source(data_source)?;
127        let url = self.url(&format!("/api/session/data/{ds}/self"))?;
128        let response = self.http.get(&url).send().await?;
129        Self::parse_response(response, "self").await
130    }
131
132    /// Creates a new user. Returns `()` on success (204 No Content).
133    pub async fn create_user(
134        &self,
135        data_source: Option<&str>,
136        user: &User,
137    ) -> Result<()> {
138        let ds = self.resolve_data_source(data_source)?;
139        let url = self.url(&format!("/api/session/data/{ds}/users"))?;
140        let response = self.http.post(&url).json(user).send().await?;
141        Self::handle_error(response, "create user").await?;
142        Ok(())
143    }
144
145    /// Updates an existing user. Returns `()` on success (204 No Content).
146    pub async fn update_user(
147        &self,
148        data_source: Option<&str>,
149        username: &str,
150        user: &User,
151    ) -> Result<()> {
152        validate_username(username)?;
153        let ds = self.resolve_data_source(data_source)?;
154        let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
155        let response = self.http.put(&url).json(user).send().await?;
156        Self::handle_error(response, &format!("user {username}")).await?;
157        Ok(())
158    }
159
160    /// Deletes a user by username. Returns `()` on success (204 No Content).
161    pub async fn delete_user(
162        &self,
163        data_source: Option<&str>,
164        username: &str,
165    ) -> Result<()> {
166        validate_username(username)?;
167        let ds = self.resolve_data_source(data_source)?;
168        let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
169        let response = self.http.delete(&url).send().await?;
170        Self::handle_error(response, &format!("user {username}")).await?;
171        Ok(())
172    }
173
174    /// Changes a user's password.
175    pub async fn update_user_password(
176        &self,
177        data_source: Option<&str>,
178        username: &str,
179        password_change: &PasswordChange,
180    ) -> Result<()> {
181        validate_username(username)?;
182        let ds = self.resolve_data_source(data_source)?;
183        let url = self.url(&format!(
184            "/api/session/data/{ds}/users/{username}/password"
185        ))?;
186        let response = self.http.put(&url).json(password_change).send().await?;
187        Self::handle_error(response, &format!("user {username} password")).await?;
188        Ok(())
189    }
190
191    /// Retrieves the permissions assigned to a user.
192    pub async fn get_user_permissions(
193        &self,
194        data_source: Option<&str>,
195        username: &str,
196    ) -> Result<UserPermissions> {
197        validate_username(username)?;
198        let ds = self.resolve_data_source(data_source)?;
199        let url = self.url(&format!(
200            "/api/session/data/{ds}/users/{username}/permissions"
201        ))?;
202        let response = self.http.get(&url).send().await?;
203        Self::parse_response(response, &format!("user {username} permissions")).await
204    }
205
206    /// Retrieves the effective (inherited + direct) permissions for a user.
207    pub async fn get_user_effective_permissions(
208        &self,
209        data_source: Option<&str>,
210        username: &str,
211    ) -> Result<UserPermissions> {
212        validate_username(username)?;
213        let ds = self.resolve_data_source(data_source)?;
214        let url = self.url(&format!(
215            "/api/session/data/{ds}/users/{username}/effectivePermissions"
216        ))?;
217        let response = self.http.get(&url).send().await?;
218        Self::parse_response(response, &format!("user {username} effective permissions"))
219            .await
220    }
221
222    /// Retrieves the groups a user belongs to.
223    pub async fn get_user_groups(
224        &self,
225        data_source: Option<&str>,
226        username: &str,
227    ) -> Result<Vec<String>> {
228        validate_username(username)?;
229        let ds = self.resolve_data_source(data_source)?;
230        let url = self.url(&format!(
231            "/api/session/data/{ds}/users/{username}/userGroups"
232        ))?;
233        let response = self.http.get(&url).send().await?;
234        Self::parse_response(response, &format!("user {username} groups")).await
235    }
236
237    /// Retrieves the history for a specific user.
238    pub async fn get_user_history(
239        &self,
240        data_source: Option<&str>,
241        username: &str,
242    ) -> Result<Vec<HistoryEntry>> {
243        validate_username(username)?;
244        let ds = self.resolve_data_source(data_source)?;
245        let url = self.url(&format!(
246            "/api/session/data/{ds}/users/{username}/history"
247        ))?;
248        let response = self.http.get(&url).send().await?;
249        Self::parse_response(response, &format!("user {username} history")).await
250    }
251
252    /// Updates the groups a user belongs to using JSON Patch operations.
253    pub async fn update_user_groups(
254        &self,
255        data_source: Option<&str>,
256        username: &str,
257        patches: &[PatchOperation],
258    ) -> Result<()> {
259        validate_username(username)?;
260        let ds = self.resolve_data_source(data_source)?;
261        let url = self.url(&format!(
262            "/api/session/data/{ds}/users/{username}/userGroups"
263        ))?;
264        let response = self.http.patch(&url).json(patches).send().await?;
265        Self::handle_error(response, &format!("user {username} groups")).await?;
266        Ok(())
267    }
268
269    /// Updates the permissions for a user using JSON Patch operations.
270    pub async fn update_user_permissions(
271        &self,
272        data_source: Option<&str>,
273        username: &str,
274        patches: &[PatchOperation],
275    ) -> Result<()> {
276        validate_username(username)?;
277        let ds = self.resolve_data_source(data_source)?;
278        let url = self.url(&format!(
279            "/api/session/data/{ds}/users/{username}/permissions"
280        ))?;
281        let response = self.http.patch(&url).json(patches).send().await?;
282        Self::handle_error(response, &format!("user {username} permissions")).await?;
283        Ok(())
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn user_serde_roundtrip() {
293        let user = User {
294            username: Some("guacadmin".to_string()),
295            password: Some("secret".to_string()),
296            attributes: Some(HashMap::from([(
297                "disabled".to_string(),
298                Some("false".to_string()),
299            )])),
300        };
301        let json = serde_json::to_string(&user).unwrap();
302        let deserialized: User = serde_json::from_str(&json).unwrap();
303        assert_eq!(user, deserialized);
304    }
305
306    #[test]
307    fn user_debug_redacts_password() {
308        let user = User {
309            username: Some("admin".to_string()),
310            password: Some("hunter2".to_string()),
311            ..Default::default()
312        };
313        let debug = format!("{user:?}");
314        assert!(!debug.contains("hunter2"), "Debug must not leak password");
315        assert!(debug.contains("<redacted>"));
316        assert!(debug.contains("admin"));
317    }
318
319    #[test]
320    fn user_skip_none_fields() {
321        let user = User::default();
322        let json = serde_json::to_value(&user).unwrap();
323        let obj = json.as_object().unwrap();
324        assert!(obj.is_empty());
325    }
326
327    #[test]
328    fn password_change_debug_redacts_both() {
329        let pc = PasswordChange {
330            old_password: Some("old-secret".to_string()),
331            new_password: Some("new-secret".to_string()),
332        };
333        let debug = format!("{pc:?}");
334        assert!(!debug.contains("old-secret"));
335        assert!(!debug.contains("new-secret"));
336        assert!(debug.contains("<redacted>"));
337    }
338
339    #[test]
340    fn password_change_serde_roundtrip() {
341        let pc = PasswordChange {
342            old_password: Some("old".to_string()),
343            new_password: Some("new".to_string()),
344        };
345        let json = serde_json::to_string(&pc).unwrap();
346        let deserialized: PasswordChange = serde_json::from_str(&json).unwrap();
347        assert_eq!(pc, deserialized);
348    }
349
350    #[test]
351    fn password_change_camel_case() {
352        let pc = PasswordChange {
353            old_password: Some("old".to_string()),
354            new_password: Some("new".to_string()),
355        };
356        let json = serde_json::to_value(&pc).unwrap();
357        assert!(json.get("oldPassword").is_some());
358        assert!(json.get("newPassword").is_some());
359    }
360
361    #[test]
362    fn user_permissions_serde_roundtrip() {
363        let json_str = r#"{
364            "connectionPermissions": {"1": ["READ"], "2": ["READ", "UPDATE"]},
365            "connectionGroupPermissions": {},
366            "sharingProfilePermissions": {"3": ["READ"]},
367            "systemPermissions": ["ADMINISTER"],
368            "userPermissions": {}
369        }"#;
370        let perms: UserPermissions = serde_json::from_str(json_str).unwrap();
371        assert_eq!(
372            perms.system_permissions,
373            Some(vec!["ADMINISTER".to_string()])
374        );
375        assert!(perms.connection_permissions.is_some());
376
377        let conn_perms = perms.connection_permissions.as_ref().unwrap();
378        assert_eq!(conn_perms.get("1"), Some(&vec!["READ".to_string()]));
379        assert_eq!(
380            conn_perms.get("2"),
381            Some(&vec!["READ".to_string(), "UPDATE".to_string()])
382        );
383
384        let json_roundtrip = serde_json::to_string(&perms).unwrap();
385        let deserialized: UserPermissions = serde_json::from_str(&json_roundtrip).unwrap();
386        assert_eq!(perms, deserialized);
387    }
388
389    #[test]
390    fn user_permissions_skip_none_fields() {
391        let perms = UserPermissions::default();
392        let json = serde_json::to_value(&perms).unwrap();
393        let obj = json.as_object().unwrap();
394        assert!(obj.is_empty());
395    }
396
397    #[test]
398    fn deserialize_user_from_api_json() {
399        let json = r#"{
400            "username": "student",
401            "attributes": {
402                "disabled": "",
403                "expired": "",
404                "access-window-start": "",
405                "access-window-end": ""
406            }
407        }"#;
408        let user: User = serde_json::from_str(json).unwrap();
409        assert_eq!(user.username.as_deref(), Some("student"));
410        assert!(user.password.is_none());
411        assert!(user.attributes.is_some());
412    }
413
414    #[test]
415    fn deserialize_user_unknown_fields_ignored() {
416        let json = r#"{"username": "test", "unknownField": 42}"#;
417        let user: User = serde_json::from_str(json).unwrap();
418        assert_eq!(user.username.as_deref(), Some("test"));
419    }
420
421    #[test]
422    fn user_permissions_extra_captures_unknown_fields() {
423        let json = r#"{
424            "connectionPermissions": {"1": ["READ"]},
425            "customPermissionType": {"x": ["ADMIN"]}
426        }"#;
427        let perms: UserPermissions = serde_json::from_str(json).unwrap();
428        assert!(perms.extra.contains_key("customPermissionType"));
429        let custom = perms.extra["customPermissionType"].as_object().unwrap();
430        assert_eq!(custom["x"], serde_json::json!(["ADMIN"]));
431    }
432
433    #[test]
434    fn user_permissions_extra_survives_roundtrip() {
435        let json = r#"{
436            "connectionPermissions": {"1": ["READ"]},
437            "futurePermissions": {"a": ["WRITE"]}
438        }"#;
439        let perms: UserPermissions = serde_json::from_str(json).unwrap();
440        let serialized = serde_json::to_string(&perms).unwrap();
441        let deserialized: UserPermissions = serde_json::from_str(&serialized).unwrap();
442        assert_eq!(perms, deserialized);
443        assert!(deserialized.extra.contains_key("futurePermissions"));
444    }
445
446    #[test]
447    fn user_null_attribute_values() {
448        let json = r#"{
449            "username": "admin",
450            "attributes": {
451                "disabled": "false",
452                "expired": null
453            }
454        }"#;
455        let user: User = serde_json::from_str(json).unwrap();
456        let attrs = user.attributes.as_ref().unwrap();
457        assert_eq!(attrs.get("disabled"), Some(&Some("false".to_string())));
458        assert_eq!(attrs.get("expired"), Some(&None));
459    }
460
461    #[test]
462    fn password_change_skip_none_fields() {
463        let pc = PasswordChange::default();
464        let json = serde_json::to_value(&pc).unwrap();
465        let obj = json.as_object().unwrap();
466        assert!(obj.is_empty());
467    }
468
469    #[test]
470    fn password_change_unknown_fields_ignored() {
471        let json = r#"{"oldPassword": "old", "unknownField": true}"#;
472        let pc: PasswordChange = serde_json::from_str(json).unwrap();
473        assert_eq!(pc.old_password.as_deref(), Some("old"));
474    }
475}