Skip to main content

tailscale_mcp/tools/
tailnet_users.rs

1//! The people who have access to the tailnet.
2//!
3//! Seven endpoints, five of which change somebody's standing: approving,
4//! suspending, restoring, deleting, and changing a role. All five refuse to act
5//! on the credential's own user — the control plane's rule, not this server's —
6//! so an admin cannot lock themselves out through the API.
7//!
8//! **The two listing filters are documented strings with an extra value.**
9//! `role` and `type` both accept everything the field itself accepts plus
10//! `all`, which means do not filter. They are separate constants
11//! ([`USER_ROLE_FILTERS`] against [`USER_ROLES`]) because a filter that
12//! accepted `all` where the field does not would be a role nobody can be set
13//! to, and the drift test holds each to its own place in the description.
14
15use rmcp::schemars::JsonSchema;
16use serde::Deserialize;
17use serde_json::Value;
18use tailscale_rest::models::user::{USER_ROLE_FILTERS, USER_ROLES, USER_TYPE_FILTERS};
19
20use crate::context::ToolContext;
21use crate::error::ToolResult;
22use crate::tools::common::{Done, answered_or, one_of, path_segment};
23
24crate::tools! {
25    /// List the tailnet's users. Answers with `{"users": [...]}`.
26    ///
27    /// `role` and `type` narrow the listing at the control plane. Both accept
28    /// `all`, which is the same as leaving them out.
29    tailnet_user_list => UserListParams, user_list,
30        toolset: TailnetUsers, tier: Read, idempotent: true;
31
32    /// Read one user by their id.
33    tailnet_user_get => UserParams, user_get,
34        toolset: TailnetUsers, tier: Read, idempotent: true;
35
36    /// Change a user's role.
37    ///
38    /// A credential owned by a user cannot change that user's own role, which
39    /// the control plane refuses rather than this server.
40    tailnet_user_role_set => UserRoleParams, user_role_set,
41        toolset: TailnetUsers, tier: Write, idempotent: true;
42
43    /// Approve a user waiting for approval.
44    ///
45    /// Does nothing if the tailnet does not require approval or the user is
46    /// already approved, which the control plane treats as success.
47    tailnet_user_approve => UserParams, user_approve,
48        toolset: TailnetUsers, tier: Write, idempotent: true;
49
50    /// Suspend a user: their devices stop connecting and they cannot sign in.
51    /// Reversible with `tailnet_user_restore`.
52    tailnet_user_suspend => UserParams, user_suspend,
53        toolset: TailnetUsers, tier: Write, idempotent: true;
54
55    /// Restore a suspended user, and their devices with them.
56    tailnet_user_restore => UserParams, user_restore,
57        toolset: TailnetUsers, tier: Write, idempotent: true;
58
59    /// Delete a user from the tailnet, along with every device they own.
60    ///
61    /// Not reversible: the user has to be invited again and their devices
62    /// re-registered.
63    tailnet_user_delete => UserParams, user_delete,
64        toolset: TailnetUsers, tier: Destructive, idempotent: true;
65}
66
67/// `/api/v2/users/<id><rest>`.
68///
69/// Not a tailnet path: a user is addressed globally, the same way a device is,
70/// and only the listing goes through the tailnet.
71fn user_path(id: &str, rest: &str) -> ToolResult<String> {
72    let id = path_segment("user_id", id)?;
73    Ok(format!("/api/v2/users/{id}{rest}"))
74}
75
76#[derive(Debug, Deserialize, JsonSchema)]
77pub struct UserListParams {
78    /// `member` for the tailnet's own users, `shared` for users a device was
79    /// shared with, `all` for both. Omit for all.
80    #[serde(default)]
81    pub user_type: Option<String>,
82    /// One of the roles — `owner`, `admin`, `member` and the rest — or `all`.
83    /// Omit for all.
84    #[serde(default)]
85    pub role: Option<String>,
86}
87
88async fn user_list(ctx: &ToolContext, params: UserListParams) -> ToolResult<Value> {
89    let client = ctx.tailnet()?;
90    let user_type = params
91        .user_type
92        .as_deref()
93        .map(|value| one_of("user_type", value, USER_TYPE_FILTERS))
94        .transpose()?;
95    let role = params
96        .role
97        .as_deref()
98        .map(|value| one_of("role", value, USER_ROLE_FILTERS))
99        .transpose()?;
100    Ok(client
101        .get(client.tailnet_path(None, "/users"))
102        // `type` is the API's name for it and `user_type` is ours: `type` is a
103        // Rust keyword, and a parameter a caller cannot spell in every client
104        // is worse than one renamed on the way out (ADR-0004).
105        .maybe_query("type", user_type)
106        .maybe_query("role", role)
107        .send_as::<Value>()
108        .await?)
109}
110
111#[derive(Debug, Deserialize, JsonSchema)]
112pub struct UserParams {
113    /// The user's id, as a listing reports it.
114    pub user_id: String,
115}
116
117async fn user_get(ctx: &ToolContext, params: UserParams) -> ToolResult<Value> {
118    let client = ctx.tailnet()?;
119    Ok(client
120        .get(user_path(&params.user_id, "")?)
121        .send_as::<Value>()
122        .await?)
123}
124
125#[derive(Debug, Deserialize, JsonSchema)]
126pub struct UserRoleParams {
127    /// The user's id, as a listing reports it.
128    pub user_id: String,
129    /// `owner`, `member`, `admin`, `it-admin`, `network-admin`,
130    /// `billing-admin` or `auditor`.
131    pub role: String,
132}
133
134async fn user_role_set(ctx: &ToolContext, params: UserRoleParams) -> ToolResult<Value> {
135    let client = ctx.tailnet()?;
136    let path = user_path(&params.user_id, "/role")?;
137    let mut body = tailscale_rest::models::user::UserRole::default();
138    // `all` is a filter, not a role: it belongs to the listing and would
139    // be a role nobody can hold.
140    body.role = Some(one_of("role", &params.role, USER_ROLES)?);
141    let answer = client.post(path).json(&body).send().await?;
142    answered_or(
143        answer,
144        Done::new("role changed")
145            .about("user_id", params.user_id)
146            .about("role", params.role),
147    )
148}
149
150/// The four standing changes, which differ only in the last path segment and
151/// in what to say afterwards.
152///
153/// Written once rather than four times: each is a bodyless `POST` whose answer
154/// is empty, so four copies would be four chances for one of them to drift.
155async fn standing(
156    ctx: &ToolContext,
157    user_id: String,
158    action: &'static str,
159    done: &'static str,
160) -> ToolResult<Value> {
161    let client = ctx.tailnet()?;
162    let path = user_path(&user_id, &format!("/{action}"))?;
163    let answer = client.post(path).send().await?;
164    answered_or(answer, Done::new(done).about("user_id", user_id))
165}
166
167async fn user_approve(ctx: &ToolContext, params: UserParams) -> ToolResult<Value> {
168    standing(ctx, params.user_id, "approve", "user approved").await
169}
170
171async fn user_suspend(ctx: &ToolContext, params: UserParams) -> ToolResult<Value> {
172    standing(ctx, params.user_id, "suspend", "user suspended").await
173}
174
175async fn user_restore(ctx: &ToolContext, params: UserParams) -> ToolResult<Value> {
176    standing(ctx, params.user_id, "restore", "user restored").await
177}
178
179async fn user_delete(ctx: &ToolContext, params: UserParams) -> ToolResult<Value> {
180    standing(ctx, params.user_id, "delete", "user deleted").await
181}