use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_rest::Secret;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, path_segment, report};
crate::tools! {
tailnet_posture_integration_list => ListParams, integration_list,
toolset: TailnetPosture, tier: Read, idempotent: true;
tailnet_posture_integration_get => GetParams, integration_get,
toolset: TailnetPosture, tier: Read, idempotent: true;
tailnet_posture_integration_create => CreateParams, integration_create,
toolset: TailnetPosture, tier: Write;
tailnet_posture_integration_update => UpdateParams, integration_update,
toolset: TailnetPosture, tier: Write, idempotent: true;
tailnet_posture_integration_delete => GetParams, integration_delete,
toolset: TailnetPosture, tier: Destructive, idempotent: true;
}
fn integration_path(id: &str) -> ToolResult<String> {
let id = path_segment("integration_id", id)?;
Ok(format!("/api/v2/posture/integrations/{id}"))
}
fn checked_provider(provider: &str) -> ToolResult<String> {
let provider = provider.trim();
if provider.is_empty() {
return Err(ToolError::invalid_args(
"`provider` is blank; name the posture provider this integration is for",
));
}
Ok(provider.to_owned())
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListParams {}
async fn integration_list(ctx: &ToolContext, _params: ListParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(client.tailnet_path(None, "/posture/integrations"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetParams {
pub integration_id: String,
}
async fn integration_get(ctx: &ToolContext, params: GetParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(integration_path(¶ms.integration_id)?)
.send_as::<Value>()
.await?)
}
#[derive(Deserialize, JsonSchema)]
pub struct CreateParams {
pub provider: String,
pub client_secret: String,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub cloud_id: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
}
impl std::fmt::Debug for CreateParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CreateParams")
.field("provider", &self.provider)
.field("client_secret", &"[redacted]")
.field("client_id", &self.client_id)
.field("cloud_id", &self.cloud_id)
.field("tenant_id", &self.tenant_id)
.finish()
}
}
impl std::fmt::Debug for UpdateParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpdateParams")
.field("integration_id", &self.integration_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[redacted]"),
)
.field("client_id", &self.client_id)
.field("cloud_id", &self.cloud_id)
.field("tenant_id", &self.tenant_id)
.finish()
}
}
#[derive(Debug, Serialize)]
struct IntegrationBody {
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")]
client_secret: Option<Secret>,
#[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
client_id: Option<String>,
#[serde(rename = "cloudId", skip_serializing_if = "Option::is_none")]
cloud_id: Option<String>,
#[serde(rename = "tenantId", skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
}
impl IntegrationBody {
fn is_empty(&self) -> bool {
self.provider.is_none()
&& self.client_secret.is_none()
&& self.client_id.is_none()
&& self.cloud_id.is_none()
&& self.tenant_id.is_none()
}
}
async fn integration_create(ctx: &ToolContext, params: CreateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if params.client_secret.trim().is_empty() {
return Err(ToolError::invalid_args(
"`client_secret` is empty; the provider's secret is what the integration \
authenticates with",
));
}
let body = IntegrationBody {
provider: Some(checked_provider(¶ms.provider)?),
client_secret: Some(Secret::new(params.client_secret)),
client_id: params.client_id,
cloud_id: params.cloud_id,
tenant_id: params.tenant_id,
};
Ok(client
.post(client.tailnet_path(None, "/posture/integrations"))
.json(&body)
.send_as::<Value>()
.await?)
}
#[derive(Deserialize, JsonSchema)]
pub struct UpdateParams {
pub integration_id: String,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub cloud_id: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
}
async fn integration_update(ctx: &ToolContext, params: UpdateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let body = IntegrationBody {
provider: None,
client_secret: params.client_secret.map(Secret::new),
client_id: params.client_id,
cloud_id: params.cloud_id,
tenant_id: params.tenant_id,
};
if body.is_empty() {
return Err(ToolError::invalid_args(
"nothing to change: give at least one of `client_secret`, `client_id`, `cloud_id` \
or `tenant_id`",
));
}
Ok(client
.patch(integration_path(¶ms.integration_id)?)
.json(&body)
.send_as::<Value>()
.await?)
}
async fn integration_delete(ctx: &ToolContext, params: GetParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.delete(integration_path(¶ms.integration_id)?)
.send()
.await?;
report(Done::new("deleted").about("integration_id", params.integration_id))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn an_integration_is_addressed_outside_the_tailnet_path() {
assert_eq!(
integration_path("pi-abc123").expect("a valid id"),
"/api/v2/posture/integrations/pi-abc123"
);
assert!(integration_path("../devices").is_err());
}
#[test]
fn a_provider_the_description_does_not_know_still_reaches_the_control_plane() {
assert_eq!(checked_provider("falcon").expect("a provider"), "falcon");
assert_eq!(
checked_provider(" something-new ").expect("sent anyway"),
"something-new",
"and it is trimmed on the way, so a stray space is not a provider"
);
let error = checked_provider(" ").expect_err("no provider");
let reported = serde_json::to_value(&error).expect("reportable");
assert_eq!(reported["code"], json!("invalid_args"));
}
#[test]
fn an_update_that_changes_nothing_is_refused_rather_than_sent() {
let body = IntegrationBody {
provider: None,
client_secret: None,
client_id: None,
cloud_id: None,
tenant_id: None,
};
assert_eq!(
serde_json::to_value(&body).expect("it serialises"),
json!({}),
"every field elides when unset, which is what the refusal detects"
);
}
#[test]
fn an_update_sends_only_what_it_was_given() {
let body = IntegrationBody {
provider: None,
client_secret: None,
client_id: Some("id-1".to_owned()),
cloud_id: None,
tenant_id: None,
};
assert_eq!(
serde_json::to_value(&body).expect("it serialises"),
json!({"clientId": "id-1"}),
"an absent field is absent, not null, so nothing else is cleared"
);
}
}