Skip to main content

netget/client/scim/
actions.rs

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}