Skip to main content

tailscale_mcp/tools/
tailnet_posture.rs

1//! Links to the device posture providers a tailnet trusts.
2//!
3//! A posture integration is a standing credential for someone else's service —
4//! CrowdStrike Falcon, Intune, Jamf Pro and the rest — which Tailscale uses to
5//! read what those services know about a device. So two things run through this
6//! module that do not apply to the devices beside it.
7//!
8//! The first is the secret. Creating an integration means handing over a client
9//! secret, and the control plane never sends one back. On the way through it is
10//! held in [`Secret`], whose `Debug` redacts: these parameter structs derive
11//! `Debug` like every other, and a derived `Debug` over a `String` is how a
12//! credential reaches a log. Updating without naming one keeps the secret that
13//! is already there, which is why the parameter is optional on an update and
14//! required on a create.
15//!
16//! The second is that there is one integration per provider. Creating a second
17//! for a provider that has one is refused by the control plane with a 409
18//! rather than replacing it, so the tool description sends a caller to the
19//! update instead of letting them find that out.
20
21use rmcp::schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use tailscale_rest::Secret;
25
26use crate::context::ToolContext;
27use crate::error::{ToolError, ToolResult};
28use crate::tools::common::{Done, path_segment, report};
29
30crate::tools! {
31    /// List the posture integrations configured for the tailnet, with the
32    /// status of each one's last sync. No secret is included.
33    tailnet_posture_integration_list => ListParams, integration_list,
34        toolset: TailnetPosture, tier: Read, idempotent: true;
35
36    /// Read one posture integration by its id.
37    tailnet_posture_integration_get => GetParams, integration_get,
38        toolset: TailnetPosture, tier: Read, idempotent: true;
39
40    /// Configure a link to a device posture provider.
41    ///
42    /// A tailnet may have only one integration per provider: if one already
43    /// exists the control plane refuses this call, and
44    /// `tailnet_posture_integration_update` is what changes the existing one.
45    /// The client secret is sent to the control plane and never comes back.
46    tailnet_posture_integration_create => CreateParams, integration_create,
47        toolset: TailnetPosture, tier: Write;
48
49    /// Change an existing posture integration. Anything not given is left as
50    /// it is, including the client secret, and the provider cannot be changed.
51    tailnet_posture_integration_update => UpdateParams, integration_update,
52        toolset: TailnetPosture, tier: Write, idempotent: true;
53
54    /// Remove a posture integration. Tailscale stops collecting posture from
55    /// that provider, and any policy rule depending on those attributes stops
56    /// matching.
57    tailnet_posture_integration_delete => GetParams, integration_delete,
58        toolset: TailnetPosture, tier: Destructive, idempotent: true;
59}
60
61fn integration_path(id: &str) -> ToolResult<String> {
62    let id = path_segment("integration_id", id)?;
63    Ok(format!("/api/v2/posture/integrations/{id}"))
64}
65
66/// The provider, present and sendable.
67///
68/// Not held to `POSTURE_PROVIDERS`. That list names six endpoint-security
69/// products, which is a market rather than a specification (Q60), and the hint
70/// this check used to carry — "if Tailscale has added a provider since this
71/// server was built, the integration has to be created in the admin console" —
72/// was an admission that the gate refused work the API would have done (Q84).
73/// The values are in the parameter's description, and the control plane
74/// refuses one it does not know.
75fn checked_provider(provider: &str) -> ToolResult<String> {
76    let provider = provider.trim();
77    if provider.is_empty() {
78        return Err(ToolError::invalid_args(
79            "`provider` is blank; name the posture provider this integration is for",
80        ));
81    }
82    Ok(provider.to_owned())
83}
84
85#[derive(Debug, Deserialize, JsonSchema)]
86pub struct ListParams {}
87
88async fn integration_list(ctx: &ToolContext, _params: ListParams) -> ToolResult<Value> {
89    let client = ctx.tailnet()?;
90    Ok(client
91        .get(client.tailnet_path(None, "/posture/integrations"))
92        .send_as::<Value>()
93        .await?)
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct GetParams {
98    /// The integration's id, as a listing reports it.
99    pub integration_id: String,
100}
101
102async fn integration_get(ctx: &ToolContext, params: GetParams) -> ToolResult<Value> {
103    let client = ctx.tailnet()?;
104    Ok(client
105        .get(integration_path(&params.integration_id)?)
106        .send_as::<Value>()
107        .await?)
108}
109
110#[derive(Deserialize, JsonSchema)]
111pub struct CreateParams {
112    /// Which provider: one of `falcon`, `intune`, `jamfpro`, `kandji`,
113    /// `kolide` or `sentinelone`.
114    pub provider: String,
115    /// The provider's client secret. Sent to the control plane and never
116    /// returned by it, so keep a copy if you need one.
117    pub client_secret: String,
118    /// The provider's client identifier, where it issues one.
119    #[serde(default)]
120    pub client_id: Option<String>,
121    /// Which of the provider's clouds — `us-1` or `eu-1` for Falcon, a region
122    /// for Intune. Leave unset for a provider that has one.
123    #[serde(default)]
124    pub cloud_id: Option<String>,
125    /// Intune's directory (tenant) identifier. Not used by any other provider.
126    #[serde(default)]
127    pub tenant_id: Option<String>,
128}
129
130/// Written rather than derived, so that the secret is not printed.
131///
132/// `missing_debug_implementations` means these need a `Debug`, and the derived
133/// one would print the credential a caller just handed us. Every other field is
134/// shown, because a redacted struct nobody can read is its own problem.
135impl std::fmt::Debug for CreateParams {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("CreateParams")
138            .field("provider", &self.provider)
139            .field("client_secret", &"[redacted]")
140            .field("client_id", &self.client_id)
141            .field("cloud_id", &self.cloud_id)
142            .field("tenant_id", &self.tenant_id)
143            .finish()
144    }
145}
146
147impl std::fmt::Debug for UpdateParams {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("UpdateParams")
150            .field("integration_id", &self.integration_id)
151            .field(
152                "client_secret",
153                &self.client_secret.as_ref().map(|_| "[redacted]"),
154            )
155            .field("client_id", &self.client_id)
156            .field("cloud_id", &self.cloud_id)
157            .field("tenant_id", &self.tenant_id)
158            .finish()
159    }
160}
161
162/// What a create or an update sends.
163///
164/// Built here rather than from the model because the two calls need different
165/// halves of it: a create requires `provider` and `clientSecret`, an update
166/// takes neither and treats every absent field as "leave it". Serialising a
167/// model of all-optional fields would send the same body for both.
168#[derive(Debug, Serialize)]
169struct IntegrationBody {
170    #[serde(skip_serializing_if = "Option::is_none")]
171    provider: Option<String>,
172    #[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")]
173    client_secret: Option<Secret>,
174    #[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
175    client_id: Option<String>,
176    #[serde(rename = "cloudId", skip_serializing_if = "Option::is_none")]
177    cloud_id: Option<String>,
178    #[serde(rename = "tenantId", skip_serializing_if = "Option::is_none")]
179    tenant_id: Option<String>,
180}
181
182impl IntegrationBody {
183    /// Whether this would send a `PATCH` that changes nothing.
184    ///
185    /// Asked of the fields rather than of the serialised body: serialising can
186    /// fail, and a check that sends the request when it cannot tell is a check
187    /// that fails open. It also keeps the secret out of a `Value` built only
188    /// to be counted.
189    fn is_empty(&self) -> bool {
190        self.provider.is_none()
191            && self.client_secret.is_none()
192            && self.client_id.is_none()
193            && self.cloud_id.is_none()
194            && self.tenant_id.is_none()
195    }
196}
197
198async fn integration_create(ctx: &ToolContext, params: CreateParams) -> ToolResult<Value> {
199    let client = ctx.tailnet()?;
200    if params.client_secret.trim().is_empty() {
201        return Err(ToolError::invalid_args(
202            "`client_secret` is empty; the provider's secret is what the integration \
203             authenticates with",
204        ));
205    }
206    let body = IntegrationBody {
207        provider: Some(checked_provider(&params.provider)?),
208        client_secret: Some(Secret::new(params.client_secret)),
209        client_id: params.client_id,
210        cloud_id: params.cloud_id,
211        tenant_id: params.tenant_id,
212    };
213    Ok(client
214        .post(client.tailnet_path(None, "/posture/integrations"))
215        .json(&body)
216        .send_as::<Value>()
217        .await?)
218}
219
220#[derive(Deserialize, JsonSchema)]
221pub struct UpdateParams {
222    /// The integration's id, as a listing reports it.
223    pub integration_id: String,
224    /// A new client secret. Omit to keep the one already configured.
225    #[serde(default)]
226    pub client_secret: Option<String>,
227    /// A new client identifier. Omit to keep the current one.
228    #[serde(default)]
229    pub client_id: Option<String>,
230    /// A new cloud. Omit to keep the current one.
231    #[serde(default)]
232    pub cloud_id: Option<String>,
233    /// A new directory (tenant) identifier. Omit to keep the current one.
234    #[serde(default)]
235    pub tenant_id: Option<String>,
236}
237
238async fn integration_update(ctx: &ToolContext, params: UpdateParams) -> ToolResult<Value> {
239    let client = ctx.tailnet()?;
240    let body = IntegrationBody {
241        // The control plane ignores `provider` on an update, so sending one
242        // would let a caller believe a provider had been changed when it had
243        // not. Deleting and creating is the way to change it.
244        provider: None,
245        client_secret: params.client_secret.map(Secret::new),
246        client_id: params.client_id,
247        cloud_id: params.cloud_id,
248        tenant_id: params.tenant_id,
249    };
250    if body.is_empty() {
251        return Err(ToolError::invalid_args(
252            "nothing to change: give at least one of `client_secret`, `client_id`, `cloud_id` \
253             or `tenant_id`",
254        ));
255    }
256    Ok(client
257        .patch(integration_path(&params.integration_id)?)
258        .json(&body)
259        .send_as::<Value>()
260        .await?)
261}
262
263async fn integration_delete(ctx: &ToolContext, params: GetParams) -> ToolResult<Value> {
264    let client = ctx.tailnet()?;
265    client
266        .delete(integration_path(&params.integration_id)?)
267        .send()
268        .await?;
269    // An empty body, as everywhere else on this surface, so the tool says what
270    // it did rather than answering `null` (Q67).
271    report(Done::new("deleted").about("integration_id", params.integration_id))
272}
273
274#[cfg(test)]
275mod tests {
276    use serde_json::json;
277
278    use super::*;
279
280    #[test]
281    fn a_provider_the_description_does_not_know_still_reaches_the_control_plane() {
282        // The list documents the parameter; it does not gate it. A provider
283        // Tailscale adds after this build should work on the day it exists
284        // rather than be refused by a copy of last year's list (Q84).
285        assert_eq!(checked_provider("falcon").expect("a provider"), "falcon");
286        assert_eq!(
287            checked_provider("  something-new  ").expect("sent anyway"),
288            "something-new",
289            "and it is trimmed on the way, so a stray space is not a provider"
290        );
291
292        // Blank is still a caller that meant to send nothing.
293        let error = checked_provider("   ").expect_err("no provider");
294        let reported = serde_json::to_value(&error).expect("reportable");
295        assert_eq!(reported["code"], json!("invalid_args"));
296    }
297
298    #[test]
299    fn an_update_sends_only_what_it_was_given() {
300        let body = IntegrationBody {
301            provider: None,
302            client_secret: None,
303            client_id: Some("id-1".to_owned()),
304            cloud_id: None,
305            tenant_id: None,
306        };
307        assert_eq!(
308            serde_json::to_value(&body).expect("it serialises"),
309            json!({"clientId": "id-1"}),
310            "an absent field is absent, not null, so nothing else is cleared"
311        );
312    }
313}