Skip to main content

netget/server/scim/
actions.rs

1use super::model_bounds;
2use crate::llm::actions::{
3    protocol_trait::{ActionResult, Protocol, Server},
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::log_template::LogTemplate;
7use crate::protocol::{EventType, SpawnContext};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct ScimProtocol;
15impl ScimProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
22    Parameter {
23        name: name.into(),
24        type_hint: kind.into(),
25        description: description.into(),
26        required,
27    }
28}
29
30pub fn action(
31    name: &str,
32    description: &str,
33    parameters: Vec<Parameter>,
34    example: Value,
35) -> ActionDefinition {
36    ActionDefinition {
37        name: name.into(),
38        description: description.into(),
39        parameters,
40        example,
41        log_template: Some(LogTemplate::new().with_info(format!("-> SCIM {name}"))),
42    }
43}
44
45fn resources_action() -> ActionDefinition {
46    action(
47        "scim_resources",
48        "Answer a list or search with the resources of that type. You may pre-filter, but need not: Rust applies the filter, sorting, startIndex/count paging and attribute selection itself and builds the ListResponse.",
49        vec![parameter("resources", "array", "Resources of the requested type, each with id and schemas, e.g. [{\"id\": \"2819c223\", \"schemas\": [\"urn:ietf:params:scim:schemas:core:2.0:User\"], \"userName\": \"bjensen\"}]", true)],
50        json!({"type":"scim_resources","resources":[{"id":"2819c223","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"bjensen","active":true}]}),
51    )
52}
53fn resource_action() -> ActionDefinition {
54    action(
55        "scim_resource",
56        "Answer get, create, replace or patch with the resource as it now is. Rust checks id and schemas, writes meta (resourceType, location, timestamps when absent) and applies attributes/excludedAttributes; a create is sent 201 with Location.",
57        vec![parameter("resource", "object", "The full resource, e.g. {\"id\": \"2819c223\", \"schemas\": [\"urn:ietf:params:scim:schemas:core:2.0:User\"], \"userName\": \"bjensen\", \"name\": {\"givenName\": \"Barbara\"}}", true)],
58        json!({"type":"scim_resource","resource":{"id":"2819c223","schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"bjensen"}}),
59    )
60}
61fn no_content_action() -> ActionDefinition {
62    action(
63        "scim_no_content",
64        "Succeed without a body (204): a delete, or a patch whose result you do not return",
65        vec![],
66        json!({"type":"scim_no_content"}),
67    )
68}
69fn error_action() -> ActionDefinition {
70    action(
71        "scim_error",
72        "Refuse with a SCIM error (RFC 7644 ยง3.12)",
73        vec![
74            parameter("status", "number", "HTTP status: 400, 403, 404, 409 (uniqueness), 412, 413, 500 or 501", true),
75            parameter("scim_type", "string", "For 400/409: invalidFilter, tooMany, uniqueness, mutability, invalidSyntax, invalidPath, noTarget, invalidValue, invalidVers or sensitive", false),
76            parameter("detail", "string", "Human-readable explanation the client sees", false),
77        ],
78        json!({"type":"scim_error","status":404,"detail":"User 2819c223 not found"}),
79    )
80}
81
82pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
83    EventType::new(
84        "scim_request",
85        "A SCIM operation on Users or Groups. Discovery (ServiceProviderConfig, ResourceTypes, Schemas) is answered by Rust from RFC 7643's schemas; you own the data.",
86        resource_action().example.clone(),
87    )
88    .with_parameters(vec![
89        parameter("operation", "string", "list (GET collection or .search), get, create (POST), replace (PUT), patch or delete", true),
90        parameter("resource_type", "string", "Which resource type the operation is on: User or Group", true),
91        parameter("id", "string", "The resource id for get, replace, patch and delete", false),
92        parameter("resource", "object", "The resource the client sent for create and replace (readOnly id and meta removed)", false),
93        parameter("operations", "array", "For patch: [{op: add|remove|replace, path, parsed_path, value}] with the path already parsed", false),
94        parameter("filter", "string", "For list: the filter as sent, e.g. userName eq \"bjensen\"", false),
95        parameter("filter_parsed", "object", "For list: the filter as a tree ({op, path, value}, {and: [...]}, {or: [...]}, {not: ...}, {value_path, filter})", false),
96        parameter("sort_by", "string", "For list: the attribute to sort on (Rust sorts)", false),
97    ])
98    .with_actions(vec![resources_action(), resource_action(), no_content_action(), error_action()])
99});
100
101fn startup(
102    name: &str,
103    kind: &str,
104    description: &str,
105    example: Value,
106    default: Option<Value>,
107) -> ParameterDefinition {
108    ParameterDefinition {
109        name: name.into(),
110        type_hint: kind.into(),
111        description: description.into(),
112        required: false,
113        example,
114        default,
115    }
116}
117
118impl Protocol for ScimProtocol {
119    fn protocol_name(&self) -> &'static str {
120        "SCIM"
121    }
122    fn stack_name(&self) -> &'static str {
123        "ETH>IP>TCP>HTTP>SCIM"
124    }
125    fn description(&self) -> &'static str {
126        "SCIM 2.0 provisioning service: Users and Groups with discovery, CRUD, PATCH, filtering, sorting and paging"
127    }
128    fn keywords(&self) -> Vec<&'static str> {
129        vec![
130            "scim",
131            "scim2",
132            "provisioning",
133            "identity",
134            "user provisioning",
135            "okta",
136            "entra",
137        ]
138    }
139    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
140        vec![]
141    }
142    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
143        vec![
144            resources_action(),
145            resource_action(),
146            no_content_action(),
147            error_action(),
148        ]
149    }
150    fn get_event_types(&self) -> Vec<EventType> {
151        vec![REQUEST_EVENT.clone()]
152    }
153    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
154        vec![
155            startup("base_path", "string", "URL path the SCIM endpoints live under (empty for the root)", json!("/scim/v2"), Some(json!(super::DEFAULT_BASE_PATH))),
156            startup("bearer_token", "string", "When set, every request must carry Authorization: Bearer <token> (compared by Rust)", json!("s3cr3t"), None),
157        ]
158    }
159    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
160        use crate::protocol::metadata::*;
161        ProtocolMetadataV2::builder()
162            .state(DevelopmentState::Experimental)
163            .privilege_requirement(PrivilegeRequirement::None)
164            .implementation("hyper HTTP/1.1; RFC 7643 schemas embedded verbatim for discovery; RFC 7644 filter grammar parsed and evaluated, PATCH paths parsed, sorting, paging and attribute projection done by Rust over handler-supplied resources")
165            .llm_control("The data: which resources exist, what create, replace and patch do to them, and refusals")
166            .e2e_testing("tests/server/scim: scim2-tester 0.5.1 (independent compliance checker) runs discovery, CRUD, PATCH add/replace/remove and attribute selection; scim2-cli 0.4.0 creates, filters, patches and deletes")
167            .notes("Users (with the Enterprise extension) and Groups only; no Bulk, /Me, ETags, changePassword or custom schemas. No storage: the handler owns every resource. 1 MiB bodies, 200 results per page, filters of 4 KiB.")
168            .request_only("SCIM answers each HTTP request; nothing is pushed")
169            .answers_on_failure()
170            .max_inbound_bytes(model_bounds::MAX_BODY_BYTES)
171            .build()
172    }
173    fn example_prompt(&self) -> &'static str {
174        "SCIM server for an HR system with three users and an Engineering group"
175    }
176    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
177        let llm = json!({"type":"open_server","base_stack":"scim","port":8080,"instruction":"An identity provider with users alice and bob, both active"});
178        let mut static_example = llm.clone();
179        static_example["event_handlers"] = json!([
180            {"event_pattern":"scim_request","handler":{"type":"static","actions":[resources_action().example]}}
181        ]);
182        let mut scripted = static_example.clone();
183        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\na={'type':'scim_resources','resources':[]} if e['operation']=='list' else {'type':'scim_error','status':404,'detail':'not found'}\nprint(json.dumps({'actions':[a]}))"});
184        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
185    }
186    fn group_name(&self) -> &'static str {
187        "Authentication"
188    }
189}
190
191impl Server for ScimProtocol {
192    fn spawn(
193        &self,
194        ctx: SpawnContext,
195    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
196    {
197        Box::pin(super::spawn(ctx))
198    }
199    fn execute_action(&self, v: Value) -> Result<ActionResult> {
200        check_answer(&v)?;
201        Ok(ActionResult::Custom {
202            name: v["type"].as_str().unwrap_or_default().into(),
203            data: v,
204        })
205    }
206}
207
208pub fn check_answer(v: &Value) -> Result<()> {
209    ensure!(model_bounds::budget_ok(v), "answer exceeds the SCIM bounds");
210    match v["type"].as_str() {
211        Some("scim_resources") => {
212            let list = v["resources"]
213                .as_array()
214                .context("resources must be an array")?;
215            ensure!(
216                list.iter().all(|r| r.is_object()),
217                "each resource is an object"
218            );
219        }
220        Some("scim_resource") => ensure!(v["resource"].is_object(), "resource must be an object"),
221        Some("scim_no_content") => {}
222        Some("scim_error") => {
223            let status = v["status"].as_u64().context("status is required")?;
224            ensure!(
225                matches!(status, 400 | 403 | 404 | 409 | 412 | 413 | 500 | 501),
226                "status must be one SCIM defines"
227            );
228            if let Some(t) = v.get("scim_type").filter(|t| !t.is_null()) {
229                let t = t.as_str().context("scim_type must be a string")?;
230                ensure!(
231                    model_bounds::SCIM_TYPES.contains(&t),
232                    "unknown scim_type {t}"
233                );
234            }
235            if let Some(d) = v.get("detail").filter(|d| !d.is_null()) {
236                ensure!(
237                    d.as_str().is_some_and(|d| d.len() <= 2048),
238                    "detail must be at most 2048 bytes"
239                );
240            }
241        }
242        _ => bail!("Unknown SCIM server action"),
243    }
244    Ok(())
245}