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, ¶ms.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, ¶ms.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, ¶ms.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}