Skip to main content

tailscale_mcp/tools/
tailnet_keys.rs

1//! Auth keys, API access tokens, OAuth clients and federated identities.
2//!
3//! One endpoint family covering four kinds of credential, which is why so much
4//! here is conditional on `key_type`. An auth key registers devices and takes
5//! `capabilities` and an expiry; an OAuth client and a federated identity take
6//! `scopes` and take neither; an API access token can be listed, read and
7//! revoked but never created here. The three lists of what `keyType` may be —
8//! `KEY_TYPES` on the way out, [`CREATE_KEY_TYPES`] on a create,
9//! [`UPDATE_KEY_TYPES`] on an update — are the description's own, and are what
10//! the refusals quote.
11//!
12//! **The secret.** Creating a key mints one, and the answer that carries it is
13//! the only one that ever will: no later read returns it. So the answer is
14//! forwarded whole, with the secret in it, and the tool description says there
15//! is no second chance. Nothing on this path logs a body, at any level, and
16//! `tests/minted_secrets.rs` asserts that against a real subscriber rather
17//! than against a reading of the code.
18//!
19//! **`capabilities` travels as Tailscale wrote it.** ADR-0004: a body that is
20//! Tailscale's is accepted in Tailscale's shape. A caller pasting the
21//! documented example gets exactly that example on the wire.
22
23use rmcp::schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use tailscale_rest::models::key::{CREATE_KEY_TYPES, UPDATE_KEY_TYPES};
27
28use crate::context::ToolContext;
29use crate::error::ToolResult;
30use crate::tools::common::{Done, one_of, path_segment, report};
31
32crate::tools! {
33    /// List the tailnet's keys: auth keys, API access tokens, OAuth clients
34    /// and federated identities. No secret is included — a key's secret is
35    /// returned only by the call that created it.
36    ///
37    /// `all` decides how wide the listing is, and defaults to true, which is
38    /// every key the credential's scopes let it see. Set it to false to see
39    /// only the keys belonging to the credential's own user.
40    tailnet_key_list => KeyListParams, key_list,
41        toolset: TailnetKeys, tier: Read, idempotent: true;
42
43    /// Read one key by its id. A revoked or expired key answers with
44    /// `invalid: true` rather than a 404.
45    tailnet_key_get => KeyParams, key_get,
46        toolset: TailnetKeys, tier: Read, idempotent: true;
47
48    /// Mint an auth key, an OAuth client or a federated identity.
49    ///
50    /// **The secret is in the answer and nowhere else.** There is no way to
51    /// read it again; a caller that loses it has to create another key and
52    /// revoke this one.
53    ///
54    /// `key_type: "auth"` takes `capabilities` and `expiry_seconds`;
55    /// `"client"` and `"federated"` take `scopes` instead, and `tags` is
56    /// required when the scopes include `devices:core` or `auth_keys`. An API
57    /// access token cannot be created here.
58    tailnet_key_create => KeyCreateParams, key_create,
59        toolset: TailnetKeys, tier: Write;
60
61    /// Reconfigure an OAuth client or a federated identity.
62    ///
63    /// Auth keys and API access tokens cannot be changed: revoke and mint
64    /// another. The secret is neither regenerated nor returned.
65    tailnet_key_update => KeyUpdateParams, key_update,
66        toolset: TailnetKeys, tier: Write, idempotent: true;
67
68    /// Revoke a key. Anything authenticating with it stops working at once,
69    /// and devices registered with an auth key are unaffected.
70    tailnet_key_delete => KeyParams, key_delete,
71        toolset: TailnetKeys, tier: Destructive, idempotent: true;
72}
73
74fn key_path(client: &tailscale_rest::Client, id: &str) -> ToolResult<String> {
75    let id = path_segment("key_id", id)?;
76    Ok(client.tailnet_path(None, &format!("/keys/{id}")))
77}
78
79/// A `keyType` checked against the list that applies where it is going.
80///
81/// The description gives the field three different lists — a response may say
82/// `api`, a create may not ask for one, an update may not turn a key into an
83/// auth key — so the check has to take which call it is for. Quoting the wrong
84/// list would be worse than not checking at all.
85/// The key type, checked against the list this call accepts.
86///
87/// `None` in and `None` out: the description gives neither endpoint a required
88/// list, so an unstated type is sent as nothing at all rather than as a default
89/// this server invented (Q80).
90fn checked_key_type(key_type: Option<&str>, allowed: &[&str]) -> ToolResult<Option<String>> {
91    key_type
92        .map(|key_type| one_of("key_type", key_type, allowed))
93        .transpose()
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct KeyListParams {
98    /// Every key the credential may see, rather than only its own user's.
99    /// Defaults to true.
100    #[serde(default)]
101    pub all: Option<bool>,
102}
103
104async fn key_list(ctx: &ToolContext, params: KeyListParams) -> ToolResult<Value> {
105    let client = ctx.tailnet()?;
106    Ok(client
107        .get(client.tailnet_path(None, "/keys"))
108        // Always sent, never elided: the description marks the parameter
109        // required while its text calls it optional, and the two readings give
110        // different listings (Q74).
111        .query("all", params.all.unwrap_or(true))
112        .send_as::<Value>()
113        .await?)
114}
115
116#[derive(Debug, Deserialize, JsonSchema)]
117pub struct KeyParams {
118    /// The key's id, as a listing reports it. Not the secret.
119    pub key_id: String,
120}
121
122async fn key_get(ctx: &ToolContext, params: KeyParams) -> ToolResult<Value> {
123    let client = ctx.tailnet()?;
124    Ok(client
125        .get(key_path(client, &params.key_id)?)
126        .send_as::<Value>()
127        .await?)
128}
129
130async fn key_delete(ctx: &ToolContext, params: KeyParams) -> ToolResult<Value> {
131    let client = ctx.tailnet()?;
132    client
133        .delete(key_path(client, &params.key_id)?)
134        .send()
135        .await?;
136    report(Done::new("key revoked").about("key_id", params.key_id))
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
140pub struct KeyCreateParams {
141    /// `auth` for a device registration key, `client` for an OAuth client,
142    /// `federated` for a federated identity. Omit it and the control plane
143    /// creates an `auth` key (Q80).
144    #[serde(default)]
145    pub key_type: Option<String>,
146    /// Up to 50 characters of letters, digits, hyphens and spaces.
147    #[serde(default)]
148    pub description: Option<String>,
149    /// Auth keys: what registering with this key produces, in Tailscale's own
150    /// shape — `{"devices": {"create": {"reusable": true, "ephemeral": false,
151    /// "preauthorized": true, "tags": ["tag:example"]}}}`. Sent as written.
152    #[serde(default)]
153    pub capabilities: Option<Value>,
154    /// Auth keys: how long the key stays usable, in seconds.
155    #[serde(default)]
156    pub expiry_seconds: Option<i64>,
157    /// OAuth clients and federated identities: what tokens minted from this
158    /// credential may do, as `auth_keys` or `devices:core:read`.
159    #[serde(default)]
160    pub scopes: Option<Vec<String>>,
161    /// The tags this credential may act as. Required when `scopes` includes
162    /// `devices:core` or `auth_keys`.
163    #[serde(default)]
164    pub tags: Option<Vec<String>>,
165    /// Federated identities: the issuer whose JWTs are accepted.
166    #[serde(default)]
167    pub issuer: Option<String>,
168    /// Federated identities: the subject a JWT must claim.
169    #[serde(default)]
170    pub subject: Option<String>,
171    /// Federated identities: the audience a JWT must claim.
172    #[serde(default)]
173    pub audience: Option<String>,
174    /// Federated identities: claims mapped to the values they must have.
175    #[serde(default)]
176    pub custom_claim_rules: Option<std::collections::BTreeMap<String, String>>,
177}
178
179/// What a create or an update sends.
180///
181/// Built here rather than serialised from a model because a create and an
182/// update take different halves of it, and because `capabilities` is passed
183/// through as the caller wrote it (ADR-0004) rather than round-tripped through
184/// a struct that would drop anything the description has not caught up with.
185#[derive(Debug, Serialize)]
186struct KeyBody {
187    #[serde(rename = "keyType", skip_serializing_if = "Option::is_none")]
188    key_type: Option<String>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    description: Option<String>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    capabilities: Option<Value>,
193    #[serde(rename = "expirySeconds", skip_serializing_if = "Option::is_none")]
194    expiry_seconds: Option<i64>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    scopes: Option<Vec<String>>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    tags: Option<Vec<String>>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    issuer: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    subject: Option<String>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    audience: Option<String>,
205    #[serde(rename = "customClaimRules", skip_serializing_if = "Option::is_none")]
206    custom_claim_rules: Option<std::collections::BTreeMap<String, String>>,
207}
208
209async fn key_create(ctx: &ToolContext, params: KeyCreateParams) -> ToolResult<Value> {
210    let client = ctx.tailnet()?;
211    let key_type = checked_key_type(params.key_type.as_deref(), CREATE_KEY_TYPES)?;
212    let body = KeyBody {
213        key_type,
214        description: params.description,
215        capabilities: params.capabilities,
216        expiry_seconds: params.expiry_seconds,
217        scopes: params.scopes,
218        tags: params.tags,
219        issuer: params.issuer,
220        subject: params.subject,
221        audience: params.audience,
222        custom_claim_rules: params.custom_claim_rules,
223    };
224    // Forwarded whole, secret and all: this answer is the only place the
225    // secret ever appears.
226    Ok(client
227        .post(client.tailnet_path(None, "/keys"))
228        .json(&body)
229        .send_as::<Value>()
230        .await?)
231}
232
233#[derive(Debug, Deserialize, JsonSchema)]
234pub struct KeyUpdateParams {
235    /// The key's id, as a listing reports it.
236    pub key_id: String,
237    /// `client` or `federated`. An auth key or an API access token cannot be
238    /// reconfigured. Omitted, it is not sent, and the control plane decides
239    /// whether an update without one is meaningful (Q80).
240    #[serde(default)]
241    pub key_type: Option<String>,
242    /// Up to 50 characters of letters, digits, hyphens and spaces.
243    #[serde(default)]
244    pub description: Option<String>,
245    /// The complete replacement list of scopes.
246    #[serde(default)]
247    pub scopes: Option<Vec<String>>,
248    /// The complete replacement list of tags.
249    #[serde(default)]
250    pub tags: Option<Vec<String>>,
251    /// Federated identities: the issuer whose JWTs are accepted.
252    #[serde(default)]
253    pub issuer: Option<String>,
254    /// Federated identities: the subject a JWT must claim.
255    #[serde(default)]
256    pub subject: Option<String>,
257    /// Federated identities: the audience a JWT must claim.
258    #[serde(default)]
259    pub audience: Option<String>,
260    /// Federated identities: claims mapped to the values they must have.
261    /// The complete replacement set, like `scopes` and `tags`.
262    #[serde(default)]
263    pub custom_claim_rules: Option<std::collections::BTreeMap<String, String>>,
264}
265
266async fn key_update(ctx: &ToolContext, params: KeyUpdateParams) -> ToolResult<Value> {
267    let client = ctx.tailnet()?;
268    let path = key_path(client, &params.key_id)?;
269    let body = KeyBody {
270        key_type: checked_key_type(params.key_type.as_deref(), UPDATE_KEY_TYPES)?,
271        description: params.description,
272        // An update carries neither: a key's capabilities and its expiry are
273        // fixed when it is minted, and the description's update body has no
274        // field for either.
275        capabilities: None,
276        expiry_seconds: None,
277        scopes: params.scopes,
278        tags: params.tags,
279        issuer: params.issuer,
280        subject: params.subject,
281        audience: params.audience,
282        custom_claim_rules: params.custom_claim_rules,
283    };
284    Ok(client.put(path).json(&body).send_as::<Value>().await?)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    #[test]
293    fn each_call_is_held_to_its_own_list_of_key_types() {
294        // The whole reason `checked_key_type` takes the list: `api` is a real
295        // key type that cannot be created, and `auth` is a real key type that
296        // cannot be updated.
297        assert!(checked_key_type(Some("auth"), CREATE_KEY_TYPES).is_ok());
298        assert!(checked_key_type(Some("api"), CREATE_KEY_TYPES).is_err());
299        assert!(checked_key_type(Some("auth"), UPDATE_KEY_TYPES).is_err());
300        assert!(checked_key_type(Some("client"), UPDATE_KEY_TYPES).is_ok());
301
302        // Unstated is not a value to check; it is a field that is not sent.
303        assert_eq!(
304            checked_key_type(None, UPDATE_KEY_TYPES).expect("no type"),
305            None
306        );
307
308        let error = checked_key_type(Some("auth"), UPDATE_KEY_TYPES).expect_err("not updatable");
309        let reported = serde_json::to_value(&error).expect("reportable");
310        let message = reported["message"].as_str().expect("a message");
311        assert!(
312            message.contains("client") && message.contains("federated"),
313            "the refusal quotes the list that applies here: {message}"
314        );
315        assert!(
316            !message.contains("api"),
317            "and not one that does not: {message}"
318        );
319    }
320
321    #[test]
322    fn a_capabilities_object_is_sent_exactly_as_it_was_given() {
323        // The criterion in its smallest form: Tailscale's documented example,
324        // through this server, unchanged.
325        let documented = json!({
326            "devices": {"create": {
327                "reusable": false, "ephemeral": false,
328                "preauthorized": false, "tags": ["tag:example"],
329            }},
330        });
331        let body = KeyBody {
332            key_type: Some("auth".to_owned()),
333            description: Some("example".to_owned()),
334            capabilities: Some(documented.clone()),
335            expiry_seconds: Some(86400),
336            scopes: None,
337            tags: None,
338            issuer: None,
339            subject: None,
340            audience: None,
341            custom_claim_rules: None,
342        };
343        let sent = serde_json::to_value(&body).expect("it serialises");
344        assert_eq!(sent["capabilities"], documented);
345        assert_eq!(
346            sent,
347            json!({
348                "keyType": "auth",
349                "description": "example",
350                "capabilities": documented,
351                "expirySeconds": 86400,
352            }),
353            "and nothing else is sent: an absent field is absent, not null"
354        );
355    }
356}