1use 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 tailnet_posture_integration_list => ListParams, integration_list,
34 toolset: TailnetPosture, tier: Read, idempotent: true;
35
36 tailnet_posture_integration_get => GetParams, integration_get,
38 toolset: TailnetPosture, tier: Read, idempotent: true;
39
40 tailnet_posture_integration_create => CreateParams, integration_create,
47 toolset: TailnetPosture, tier: Write;
48
49 tailnet_posture_integration_update => UpdateParams, integration_update,
52 toolset: TailnetPosture, tier: Write, idempotent: true;
53
54 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
66fn 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 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(¶ms.integration_id)?)
106 .send_as::<Value>()
107 .await?)
108}
109
110#[derive(Deserialize, JsonSchema)]
111pub struct CreateParams {
112 pub provider: String,
115 pub client_secret: String,
118 #[serde(default)]
120 pub client_id: Option<String>,
121 #[serde(default)]
124 pub cloud_id: Option<String>,
125 #[serde(default)]
127 pub tenant_id: Option<String>,
128}
129
130impl 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#[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 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(¶ms.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 pub integration_id: String,
224 #[serde(default)]
226 pub client_secret: Option<String>,
227 #[serde(default)]
229 pub client_id: Option<String>,
230 #[serde(default)]
232 pub cloud_id: Option<String>,
233 #[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 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(¶ms.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(¶ms.integration_id)?)
267 .send()
268 .await?;
269 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 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 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}