ts_control_serde/user.rs
1use alloc::borrow::Cow;
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}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// `Login::login_name` and `Login::display_name` are IdP-authored human text typed
105 /// `Cow<'a, str>` / `Option<Cow<'a, str>>` so they tolerate JSON escapes. Go's `json.Marshal`
106 /// HTML-escapes `&` → `&` by default, so a display name like `Tom & Jerry` arrives on the
107 /// wire as `Tom & Jerry`. A bare `&'a str` cannot zero-copy-borrow a string serde must
108 /// unescape and fails the WHOLE `Login` decode (`invalid type: string "...", expected a borrowed
109 /// string`) — which silently drops the enclosing struct (the user, the netmap). With `Cow`,
110 /// serde owns the unescaped value and the decode succeeds.
111 #[test]
112 fn login_with_go_html_escaped_display_name_decodes() {
113 // Exactly what Go emits for `Tom & Jerry` (SetEscapeHTML(true) is the Marshal default).
114 const TEST: &str = r#"{ "ID": 1, "Provider": "google", "LoginName": "a@b.com", "DisplayName": "Tom & Jerry" }"#;
115 let login = serde_json::from_str::<Login>(TEST)
116 .expect("Login with a Go-HTML-escaped DisplayName must decode");
117 assert_eq!(login.login_name, "a@b.com");
118 assert_eq!(login.display_name.as_deref(), Some("Tom & Jerry"));
119 }
120
121 /// The other escape forms (`\n`, `\"`, `\\`) on both the bare `login_name` and the
122 /// `Option<Cow>` `display_name` decode and unescape too.
123 #[test]
124 fn login_with_control_escapes_decodes() {
125 const TEST: &str = r#"{
126 "ID": 1,
127 "Provider": "google",
128 "LoginName": "a\nb@\"c\\d.com",
129 "DisplayName": "line1\nline2\"q\\z"
130 }"#;
131 let login = serde_json::from_str::<Login>(TEST)
132 .expect("Login with control-character escapes must decode");
133 assert_eq!(login.login_name, "a\nb@\"c\\d.com");
134 assert_eq!(login.display_name.as_deref(), Some("line1\nline2\"q\\z"));
135 }
136
137 /// `UserProfile::login_name` (bare `Cow`) and `UserProfile::display_name` (`Option<Cow>`) — the
138 /// display-facing identity joined onto a peer — also decode with a Go-HTML-escaped `&` and the
139 /// control escapes. A failure here would drop the owning user's profile.
140 #[test]
141 fn user_profile_with_escaped_fields_decodes() {
142 const TEST: &str = r#"{ "ID": 7, "LoginName": "a@b.com", "DisplayName": "Tom & Jerry" }"#;
143 let profile = serde_json::from_str::<UserProfile>(TEST)
144 .expect("UserProfile with an escaped DisplayName must decode");
145 assert_eq!(profile.login_name, "a@b.com");
146 assert_eq!(profile.display_name.as_deref(), Some("Tom & Jerry"));
147
148 const TEST_CTRL: &str =
149 r#"{ "ID": 7, "LoginName": "a\nb@c.com", "DisplayName": "x\"y\\z" }"#;
150 let profile = serde_json::from_str::<UserProfile>(TEST_CTRL)
151 .expect("UserProfile with control escapes must decode");
152 assert_eq!(profile.login_name, "a\nb@c.com");
153 assert_eq!(profile.display_name.as_deref(), Some("x\"y\\z"));
154 }
155
156 /// The no-escape fast path still decodes (and borrows zero-copy, though that is not observable
157 /// from outside): plain values pass through unchanged.
158 #[test]
159 fn login_without_escape_decodes() {
160 const TEST: &str = r#"{ "ID": 1, "Provider": "google", "LoginName": "alice@example.com", "DisplayName": "Alice Smith" }"#;
161 let login =
162 serde_json::from_str::<Login>(TEST).expect("Login with plain fields must decode");
163 assert_eq!(login.login_name, "alice@example.com");
164 assert_eq!(login.display_name.as_deref(), Some("Alice Smith"));
165 }
166}