Skip to main content

ts_control_serde/
user.rs

1use alloc::{borrow::Cow, vec::Vec};
2
3use chrono::{DateTime, Utc};
4use serde::Deserialize;
5use url::Url;
6
7/// A unique integer ID for a [`Login`]. This is not used by Tailscale node software, but is used
8/// in the control plane.
9pub type LoginId = i64;
10
11/// A unique integer ID for a [`User`].
12pub type UserId = i64;
13
14/// Represents a [`User`] from a specific identity provider (IdP), not associated with any
15/// particular Tailnet.
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
17#[serde(rename_all = "PascalCase")]
18pub struct Login<'a> {
19    /// The unique integer ID of this login. Unused on the Tailscale node-side, but used by the
20    /// control plane.
21    #[serde(rename = "ID")]
22    pub id: LoginId,
23    /// A string representation of the IdP itself, e.g. "google", "github", "okta_foo", etc.
24    #[serde(borrow)]
25    pub provider: &'a str,
26    /// An email address or "email-ish" string (e.g. "alice@github") associated with this Tailscale
27    /// user, according to the IdP.
28    #[serde(borrow)]
29    pub login_name: Cow<'a, str>,
30    /// If populated, the display name of this Tailscale user, according to the IdP. Can be
31    /// overridden by a value in the [`User::display_name`] field.
32    #[serde(borrow, default)]
33    pub display_name: Option<Cow<'a, str>>,
34    /// If populated, a URL to a profile picture representing this Tailscale user, according to the
35    /// IdP. Can be overridden by a value in the [`User::profile_pic_url`] field.
36    #[serde(
37        rename = "ProfilePicURL",
38        deserialize_with = "crate::util::deserialize_string_option",
39        default
40    )]
41    pub profile_pic_url: Option<Url>,
42}
43
44/// A Tailscale user.
45///
46/// A [`User`] can have multiple [`Login`]s associated with it (e.g. gmail and github oauth),
47/// although as of 2019, none of the UIs support this.
48///
49/// Some fields are inherited from the [`Login`]s and can be overridden, such as
50/// [`User::display_name`] and [`User::profile_pic_url`].
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
52#[serde(rename_all = "PascalCase")]
53pub struct User<'a> {
54    /// The unique integer ID of this Tailscale user.
55    #[serde(rename = "ID")]
56    pub id: UserId,
57    /// If populated, the display name of this Tailscale user. Overrides the value in any IdP-
58    /// provided [`Login::display_name`] field.
59    #[serde(borrow, default)]
60    pub display_name: Option<Cow<'a, str>>,
61    /// If populated, a URL to a profile picture representing this Tailscale user. Overrides the
62    /// IdP-provided value in any [`Login::profile_pic_url`] field.
63    #[serde(
64        rename = "ProfilePicURL",
65        deserialize_with = "crate::util::deserialize_string_option",
66        default
67    )]
68    pub profile_pic_url: Option<Url>,
69    /// The date and time that this Tailscale user was created, in the UTC timezone.
70    #[serde(default)]
71    pub created: Option<DateTime<Utc>>,
72}
73
74/// Display-friendly data for a [`User`]. Includes the [`Login::login_name`] for display purposes.
75/// but *not* the [`Login::provider`]. Also includes derived data from one of the [`Login`]s
76/// associated with a [`User`].
77#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
78#[serde(rename_all = "PascalCase")]
79pub struct UserProfile<'a> {
80    /// The unique integer ID of this Tailscale user this [`UserProfile`] is associated with.
81    #[serde(rename = "ID")]
82    pub id: UserId,
83    /// An email address or "email-ish" string (e.g. "alice@github") associated with this Tailscale
84    /// user's [`UserProfile`], according to the IdP. For display purposes only.
85    #[serde(borrow, default)]
86    pub login_name: Cow<'a, str>,
87    /// If populated, the display name of this Tailscale user (e.g. "Alice Smith"), according to
88    /// the IdP.
89    #[serde(borrow, default)]
90    pub display_name: Option<Cow<'a, str>>,
91    /// If populated, a URL to a profile picture representing this Tailscale user.
92    #[serde(
93        rename = "ProfilePicURL",
94        deserialize_with = "crate::util::deserialize_string_option",
95        default
96    )]
97    pub profile_pic_url: Option<Url>,
98    /// A subset of the groups that contain this user and that the coordination server was
99    /// configured to report to this node: either SCIM groups (e.g. `engineering@example.com`) or
100    /// group names from the tailnet policy document (e.g. `group:eng`).
101    ///
102    /// This is the one attribute of an owning user that a node cannot re-derive from anything else
103    /// control sends it, so it is what an embedder authorising an inbound connection on group
104    /// membership needs (Go surfaces it through `WhoIs`).
105    ///
106    /// Control sorts the list when it loads the profile from storage, so it arrives sorted; it is
107    /// carried through verbatim rather than re-sorted here. The field is `omitempty` on the wire —
108    /// an older control server, or a tailnet that reports no groups to this node, simply omits it —
109    /// so it decodes to an **empty** list, never a missing profile. `null` (what a non-Go control
110    /// plane emits for an empty slice) lands the same way, per the crate-wide `null_to_default`
111    /// convention for bare `Vec` fields.
112    #[serde(borrow, default, deserialize_with = "crate::util::null_to_default")]
113    pub groups: Vec<Cow<'a, str>>,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    /// `Login::login_name` and `Login::display_name` are IdP-authored human text typed
121    /// `Cow<'a, str>` / `Option<Cow<'a, str>>` so they tolerate JSON escapes. Go's `json.Marshal`
122    /// HTML-escapes `&` → `&` by default, so a display name like `Tom & Jerry` arrives on the
123    /// wire as `Tom & Jerry`. A bare `&'a str` cannot zero-copy-borrow a string serde must
124    /// unescape and fails the WHOLE `Login` decode (`invalid type: string "...", expected a borrowed
125    /// string`) — which silently drops the enclosing struct (the user, the netmap). With `Cow`,
126    /// serde owns the unescaped value and the decode succeeds.
127    #[test]
128    fn login_with_go_html_escaped_display_name_decodes() {
129        // Exactly what Go emits for `Tom & Jerry` (SetEscapeHTML(true) is the Marshal default).
130        const TEST: &str = r#"{ "ID": 1, "Provider": "google", "LoginName": "a@b.com", "DisplayName": "Tom & Jerry" }"#;
131        let login = serde_json::from_str::<Login>(TEST)
132            .expect("Login with a Go-HTML-escaped DisplayName must decode");
133        assert_eq!(login.login_name, "a@b.com");
134        assert_eq!(login.display_name.as_deref(), Some("Tom & Jerry"));
135    }
136
137    /// The other escape forms (`\n`, `\"`, `\\`) on both the bare `login_name` and the
138    /// `Option<Cow>` `display_name` decode and unescape too.
139    #[test]
140    fn login_with_control_escapes_decodes() {
141        const TEST: &str = r#"{
142            "ID": 1,
143            "Provider": "google",
144            "LoginName": "a\nb@\"c\\d.com",
145            "DisplayName": "line1\nline2\"q\\z"
146        }"#;
147        let login = serde_json::from_str::<Login>(TEST)
148            .expect("Login with control-character escapes must decode");
149        assert_eq!(login.login_name, "a\nb@\"c\\d.com");
150        assert_eq!(login.display_name.as_deref(), Some("line1\nline2\"q\\z"));
151    }
152
153    /// `UserProfile::login_name` (bare `Cow`) and `UserProfile::display_name` (`Option<Cow>`) — the
154    /// display-facing identity joined onto a peer — also decode with a Go-HTML-escaped `&` and the
155    /// control escapes. A failure here would drop the owning user's profile.
156    #[test]
157    fn user_profile_with_escaped_fields_decodes() {
158        const TEST: &str = r#"{ "ID": 7, "LoginName": "a@b.com", "DisplayName": "Tom & Jerry" }"#;
159        let profile = serde_json::from_str::<UserProfile>(TEST)
160            .expect("UserProfile with an escaped DisplayName must decode");
161        assert_eq!(profile.login_name, "a@b.com");
162        assert_eq!(profile.display_name.as_deref(), Some("Tom & Jerry"));
163
164        const TEST_CTRL: &str =
165            r#"{ "ID": 7, "LoginName": "a\nb@c.com", "DisplayName": "x\"y\\z" }"#;
166        let profile = serde_json::from_str::<UserProfile>(TEST_CTRL)
167            .expect("UserProfile with control escapes must decode");
168        assert_eq!(profile.login_name, "a\nb@c.com");
169        assert_eq!(profile.display_name.as_deref(), Some("x\"y\\z"));
170    }
171
172    /// `UserProfile.Groups` is the group membership control reports for the owning user, and the
173    /// only user attribute a node cannot re-derive locally. It must survive the wire boundary in
174    /// the order control sent it (control sorts it when it loads the profile from storage).
175    #[test]
176    fn user_profile_carries_groups() {
177        const TEST: &str = r#"{
178            "ID": 7,
179            "LoginName": "alice@example.com",
180            "DisplayName": "Alice Smith",
181            "Groups": ["engineering@example.com", "group:eng", "group:ops"]
182        }"#;
183        let profile =
184            serde_json::from_str::<UserProfile>(TEST).expect("UserProfile with Groups must decode");
185        assert_eq!(
186            profile.groups,
187            ["engineering@example.com", "group:eng", "group:ops"]
188        );
189    }
190
191    /// `Groups` is `omitempty` in Go, so a control server with nothing to report — or one older
192    /// than the field — sends no key at all. That must decode to a profile with an EMPTY group
193    /// list, not a failed decode (which would silently drop the whole profile). A wire `null`,
194    /// which a non-Go control plane can emit for an empty slice, must land the same way.
195    #[test]
196    fn user_profile_without_groups_decodes_to_an_empty_list() {
197        const ABSENT: &str = r#"{ "ID": 7, "LoginName": "alice@example.com" }"#;
198        let profile = serde_json::from_str::<UserProfile>(ABSENT)
199            .expect("UserProfile without Groups must still decode");
200        assert_eq!(profile.login_name, "alice@example.com");
201        assert!(profile.groups.is_empty());
202
203        const EMPTY: &str = r#"{ "ID": 7, "LoginName": "alice@example.com", "Groups": [] }"#;
204        assert!(
205            serde_json::from_str::<UserProfile>(EMPTY)
206                .expect("an explicit empty Groups must decode")
207                .groups
208                .is_empty()
209        );
210
211        const NULL: &str = r#"{ "ID": 7, "LoginName": "alice@example.com", "Groups": null }"#;
212        assert!(
213            serde_json::from_str::<UserProfile>(NULL)
214                .expect("a wire null Groups must decode, not fail the profile")
215                .groups
216                .is_empty()
217        );
218    }
219
220    /// Group names are control-authored text on the same JSON path as the display name, so they
221    /// carry the same escaping hazard: Go's `json.Marshal` HTML-escapes `&`, and a group named
222    /// `R&D` arrives as `R\u0026D`. `Cow` (not `&str`) is what lets serde own the unescaped value
223    /// instead of failing the whole `UserProfile` decode.
224    #[test]
225    fn user_profile_groups_with_escapes_decode() {
226        const TEST: &str =
227            r#"{ "ID": 7, "LoginName": "a@b.com", "Groups": ["R\u0026D", "group:q\"z"] }"#;
228        let profile = serde_json::from_str::<UserProfile>(TEST)
229            .expect("UserProfile with escaped group names must decode");
230        assert_eq!(profile.groups, ["R&D", "group:q\"z"]);
231    }
232
233    /// The no-escape fast path still decodes (and borrows zero-copy, though that is not observable
234    /// from outside): plain values pass through unchanged.
235    #[test]
236    fn login_without_escape_decodes() {
237        const TEST: &str = r#"{ "ID": 1, "Provider": "google", "LoginName": "alice@example.com", "DisplayName": "Alice Smith" }"#;
238        let login =
239            serde_json::from_str::<Login>(TEST).expect("Login with plain fields must decode");
240        assert_eq!(login.login_name, "alice@example.com");
241        assert_eq!(login.display_name.as_deref(), Some("Alice Smith"));
242    }
243}