1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::scim::actions::{action, parameter};
8use crate::server::scim::{model_bounds, query};
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct ScimClientProtocol;
15impl ScimClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn rt() -> crate::llm::actions::Parameter {
22 parameter(
23 "resource_type",
24 "string",
25 "Endpoint name of the resource type, e.g. Users or Groups",
26 true,
27 )
28}
29fn rid() -> crate::llm::actions::Parameter {
30 parameter("id", "string", "The resource id", true)
31}
32fn list_action() -> ActionDefinition {
33 action(
34 "scim_list",
35 "Query a resource type (GET with filter, sortBy, startIndex, count, attributes); Rust checks the filter syntax before sending",
36 vec![
37 rt(),
38 parameter("filter", "string", "SCIM filter, e.g. userName eq \"bjensen\" or emails[type eq \"work\"]", false),
39 parameter("sort_by", "string", "Attribute to sort on, e.g. userName", false),
40 parameter("sort_order", "string", "ascending or descending", false),
41 parameter("start_index", "number", "1-based index of the first result", false),
42 parameter("count", "number", "Maximum results to return", false),
43 parameter("attributes", "string", "Comma-separated attributes to return, e.g. userName,emails", false),
44 ],
45 json!({"type":"scim_list","resource_type":"Users","filter":"userName sw \"b\"","count":10}),
46 )
47}
48fn get_action() -> ActionDefinition {
49 action(
50 "scim_get",
51 "Read one resource",
52 vec![rt(), rid()],
53 json!({"type":"scim_get","resource_type":"Users","id":"2819c223"}),
54 )
55}
56fn create_action() -> ActionDefinition {
57 action(
58 "scim_create",
59 "Create a resource (POST); Rust adds the core schema URN when schemas is missing",
60 vec![rt(), parameter("resource", "object", "The resource, e.g. {\"userName\": \"bjensen\", \"name\": {\"givenName\": \"Barbara\"}}", true)],
61 json!({"type":"scim_create","resource_type":"Users","resource":{"userName":"bjensen","active":true}}),
62 )
63}
64fn replace_action() -> ActionDefinition {
65 action(
66 "scim_replace",
67 "Replace a resource (PUT)",
68 vec![
69 rt(),
70 rid(),
71 parameter("resource", "object", "The complete new resource", true),
72 ],
73 json!({"type":"scim_replace","resource_type":"Users","id":"2819c223","resource":{"userName":"bjensen","active":false}}),
74 )
75}
76fn patch_action() -> ActionDefinition {
77 action(
78 "scim_patch",
79 "Modify a resource with PatchOp operations; Rust checks each op and path before sending",
80 vec![rt(), rid(), parameter("operations", "array", "[{op: add|remove|replace, path, value}], e.g. [{\"op\": \"replace\", \"path\": \"active\", \"value\": false}]", true)],
81 json!({"type":"scim_patch","resource_type":"Users","id":"2819c223","operations":[{"op":"replace","path":"active","value":false}]}),
82 )
83}
84fn delete_action() -> ActionDefinition {
85 action(
86 "scim_delete",
87 "Delete a resource",
88 vec![rt(), rid()],
89 json!({"type":"scim_delete","resource_type":"Users","id":"2819c223"}),
90 )
91}
92fn disconnect_action() -> ActionDefinition {
93 action(
94 "disconnect",
95 "Stop this SCIM client",
96 vec![],
97 json!({"type":"disconnect"}),
98 )
99}
100fn actions() -> Vec<ActionDefinition> {
101 vec![
102 list_action(),
103 get_action(),
104 create_action(),
105 replace_action(),
106 patch_action(),
107 delete_action(),
108 disconnect_action(),
109 ]
110}
111
112pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
113 EventType::new("scim_connected", "The service's ServiceProviderConfig and ResourceTypes were read", list_action().example.clone())
114 .with_parameters(vec![
115 parameter("resource_types", "array", "[{name, endpoint, schema}] the service offers", true),
116 parameter("features", "object", "What ServiceProviderConfig says is supported: {patch, filter, sort, bulk, etag, changePassword}", true),
117 ])
118 .with_actions(actions())
119});
120pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
121 EventType::new(
122 "scim_response",
123 "The service's answer to the last operation",
124 disconnect_action().example.clone(),
125 )
126 .with_parameters(vec![
127 parameter(
128 "operation",
129 "string",
130 "list, get, create, replace, patch or delete",
131 true,
132 ),
133 parameter(
134 "status",
135 "number",
136 "HTTP status of the answer: 200, 201 created, 204 no content, or the error status",
137 true,
138 ),
139 parameter(
140 "resource",
141 "object",
142 "The resource returned by get, create, replace or patch",
143 false,
144 ),
145 parameter("total_results", "number", "For list: totalResults", false),
146 parameter(
147 "resources",
148 "array",
149 "For list: the page of resources",
150 false,
151 ),
152 parameter(
153 "error",
154 "object",
155 "SCIM error: {status, scim_type, detail}",
156 false,
157 ),
158 ])
159 .with_actions(actions())
160});
161
162impl Protocol for ScimClientProtocol {
163 fn protocol_name(&self) -> &'static str {
164 "SCIM"
165 }
166 fn stack_name(&self) -> &'static str {
167 "ETH>IP>TCP>HTTP>SCIM"
168 }
169 fn keywords(&self) -> Vec<&'static str> {
170 vec!["scim", "scim2", "provisioning client"]
171 }
172 fn description(&self) -> &'static str {
173 "SCIM 2.0 client: discovers a service, then lists, reads, creates, replaces, patches and deletes Users and Groups"
174 }
175 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
176 actions()
177 }
178 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
179 vec![]
180 }
181 fn get_event_types(&self) -> Vec<EventType> {
182 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
183 }
184 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
185 let p =
186 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
187 ParameterDefinition {
188 name: name.into(),
189 type_hint: kind.into(),
190 description: description.into(),
191 required: false,
192 example,
193 default,
194 }
195 };
196 vec![
197 p(
198 "scheme",
199 "string",
200 "URL scheme to reach the service with: https (default) or http",
201 json!("http"),
202 Some(json!(super::DEFAULT_SCHEME)),
203 ),
204 p(
205 "base_path",
206 "string",
207 "Path the service's SCIM endpoints live under (empty for the root)",
208 json!("/scim/v2"),
209 Some(json!(crate::server::scim::DEFAULT_BASE_PATH)),
210 ),
211 p(
212 "bearer_token",
213 "string",
214 "Token sent as Authorization: Bearer",
215 json!("s3cr3t"),
216 None,
217 ),
218 ]
219 }
220 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
221 use crate::protocol::metadata::*;
222 ProtocolMetadataV2::builder()
223 .state(DevelopmentState::Experimental)
224 .privilege_requirement(PrivilegeRequirement::None)
225 .implementation("Shared http_fetch client (reqwest natively, no redirects); application/scim+json; ServiceProviderConfig and ResourceTypes discovery; filters and PATCH paths checked with the server's own parser; ListResponse and SCIM errors parsed")
226 .llm_control("Which resources to query, create, change or delete, and what to do with each answer")
227 .e2e_testing("tests/client/scim: scim2-server 0.4.0 (independent) answers discovery, create, filtered and sorted lists, get, PATCH, PUT, a uniqueness conflict and delete")
228 .notes("No Bulk or /Me; one request at a time; 1 MiB answers.")
229 .max_inbound_bytes(model_bounds::MAX_BODY_BYTES)
230 .build()
231 }
232 fn example_prompt(&self) -> &'static str {
233 "Provision user bjensen into the SCIM service at 127.0.0.1:8080 and add her to Engineering"
234 }
235 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
236 let llm = json!({"type":"open_client","protocol":"scim","remote_addr":"127.0.0.1:8080","instruction":"List all users","startup_params":{"scheme":"http"}});
237 let mut static_example = llm.clone();
238 static_example["event_handlers"] = json!([
239 {"event_pattern":"scim_connected","handler":{"type":"static","actions":[list_action().example]}},
240 {"event_pattern":"scim_response","handler":{"type":"static","actions":[]}}
241 ]);
242 let mut scripted = static_example.clone();
243 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
244 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
245 }
246 fn group_name(&self) -> &'static str {
247 "Authentication"
248 }
249}
250
251impl Client for ScimClientProtocol {
252 fn connect(
253 &self,
254 ctx: ConnectContext,
255 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
256 {
257 Box::pin(super::connect(ctx))
258 }
259 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
260 let ty = v["type"].as_str().unwrap_or_default();
261 if ty == "disconnect" {
262 return Ok(ClientActionResult::Disconnect);
263 }
264 ensure!(
265 matches!(
266 ty,
267 "scim_list"
268 | "scim_get"
269 | "scim_create"
270 | "scim_replace"
271 | "scim_patch"
272 | "scim_delete"
273 ),
274 "Unknown SCIM client action"
275 );
276 let rt = v["resource_type"]
277 .as_str()
278 .context("resource_type is required")?;
279 ensure!(
280 !rt.is_empty() && rt.len() <= 64 && rt.bytes().all(|b| b.is_ascii_alphanumeric()),
281 "resource_type is an endpoint name like Users"
282 );
283 ensure!(
284 model_bounds::budget_ok(&v),
285 "action exceeds the SCIM bounds"
286 );
287 if matches!(
288 ty,
289 "scim_get" | "scim_replace" | "scim_patch" | "scim_delete"
290 ) {
291 ensure!(
292 v["id"]
293 .as_str()
294 .is_some_and(|i| !i.is_empty() && i.len() <= 256 && !i.contains('/')),
295 "id is required"
296 );
297 }
298 match ty {
299 "scim_list" => {
300 if let Some(f) = v["filter"].as_str() {
301 query::parse_filter(f)?;
302 }
303 if let Some(s) = v["sort_by"].as_str() {
304 query::AttrPath::parse(s)?;
305 }
306 query::parse_list(v["attributes"].as_str())?;
307 if let Some(o) = v["sort_order"].as_str() {
308 ensure!(
309 o == "ascending" || o == "descending",
310 "sort_order is ascending or descending"
311 );
312 }
313 }
314 "scim_create" | "scim_replace" => {
315 ensure!(v["resource"].is_object(), "resource must be an object")
316 }
317 "scim_patch" => {
318 let ops = v["operations"]
319 .as_array()
320 .filter(|o| !o.is_empty() && o.len() <= model_bounds::MAX_PATCH_OPERATIONS)
321 .context("operations must hold 1..64 operations")?;
322 for o in ops {
323 let op = o["op"]
324 .as_str()
325 .map(str::to_ascii_lowercase)
326 .context("each operation needs op")?;
327 ensure!(
328 matches!(op.as_str(), "add" | "remove" | "replace"),
329 "op is add, remove or replace"
330 );
331 if let Some(p) = o["path"].as_str() {
332 query::PatchPath::parse(p)?;
333 } else if op == "remove" {
334 bail!("remove needs a path");
335 }
336 }
337 }
338 _ => {}
339 }
340 Ok(ClientActionResult::Custom {
341 name: ty.into(),
342 data: v,
343 })
344 }
345}