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}