Skip to main content

netget/server/scim/
mod.rs

1//! SCIM 2.0 service (RFC 7643/7644) over HTTP/1.1. Rust owns discovery, request parsing,
2//! filters, PATCH paths, sorting, paging, projection and meta; the handler owns the data.
3pub mod actions;
4pub mod model_bounds;
5pub mod query;
6pub mod schema;
7
8use crate::llm::action_helper::call_llm;
9use crate::llm::actions::protocol_trait::ActionResult;
10use crate::logging::emit::Log;
11use crate::protocol::{Event, SpawnContext};
12use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter, DEFAULT_MAX_CONNECTIONS};
13use crate::server::connection::ConnectionId;
14use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
15use bytes::Bytes;
16use http_body_util::{BodyExt, Full, Limited};
17use hyper::{
18    body::Incoming, header, server::conn::http1, service::service_fn, Method, Request, Response,
19    StatusCode,
20};
21use hyper_util::rt::{TokioIo, TokioTimer};
22use model_bounds::{error_body, MAX_BODY_BYTES, MAX_PATCH_OPERATIONS, MAX_RESULTS};
23use query::{AttrPath, Filter};
24use schema::ResourceType;
25use serde_json::{json, Value};
26use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
27
28pub const DEFAULT_BASE_PATH: &str = "/scim/v2";
29const HEADER_TIMEOUT: Duration = Duration::from_secs(30);
30const BODY_TIMEOUT: Duration = Duration::from_secs(30);
31const CONTENT_TYPE: &str = "application/scim+json";
32
33struct Shared {
34    ctx: SpawnContext,
35    base: String,
36    token: Option<String>,
37    local: SocketAddr,
38}
39
40pub async fn spawn(ctx: SpawnContext) -> anyhow::Result<SocketAddr> {
41    let p = ctx.startup_params.as_ref();
42    let base = p
43        .map(|p| p.get_optional_string("base_path"))
44        .transpose()?
45        .flatten()
46        .unwrap_or_else(|| DEFAULT_BASE_PATH.to_owned());
47    let base = base.trim_end_matches('/').to_owned();
48    anyhow::ensure!(
49        (base.is_empty() || base.starts_with('/'))
50            && base.len() <= 128
51            && base
52                .bytes()
53                .all(|b| b.is_ascii_alphanumeric() || b"/-_.".contains(&b)),
54        "base_path must be empty or an absolute path"
55    );
56    let token = p
57        .map(|p| p.get_optional_string("bearer_token"))
58        .transpose()?
59        .flatten();
60    if let Some(t) = &token {
61        anyhow::ensure!(
62            !t.is_empty() && t.len() <= 512 && t.bytes().all(|b| b.is_ascii_graphic()),
63            "bearer_token must be printable ASCII"
64        );
65    }
66    let listener = tokio::net::TcpListener::bind(ctx.legacy_listen_addr()).await?;
67    let local = listener.local_addr()?;
68    Log::new(Some(&ctx.status_tx)).info(format!("SCIM service at http://{local}{base}/"));
69    let shared = Arc::new(Shared {
70        ctx: ctx.clone(),
71        base,
72        token,
73        local,
74    });
75    let server_id = ctx.server_id;
76    let accept = tokio::spawn(async move {
77        let limiter = ConnectionLimiter::new(DEFAULT_MAX_CONNECTIONS);
78        loop {
79            let (stream, peer, permit) = match accept_bounded(&listener, &limiter, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", "SCIM", Some(&shared.ctx.status_tx)).await {
80                Ok(v) => v,
81                Err(_) => break,
82            };
83            let id = ConnectionId::new(shared.ctx.state.get_next_unified_id().await);
84            let now = crate::utils::clock::Instant::now();
85            shared
86                .ctx
87                .state
88                .add_connection_to_server(
89                    server_id,
90                    ConnectionState {
91                        id,
92                        remote_addr: peer,
93                        local_addr: local,
94                        bytes_sent: 0,
95                        bytes_received: 0,
96                        packets_sent: 0,
97                        packets_received: 0,
98                        last_activity: now,
99                        status: ConnectionStatus::Active,
100                        status_changed_at: now,
101                        protocol_info: ProtocolConnectionInfo::empty(),
102                    },
103                )
104                .await;
105            let child = shared.clone();
106            shared
107                .ctx
108                .state
109                .spawn_server_task(server_id, async move {
110                    let _permit = permit;
111                    let svc_shared = child.clone();
112                    let service = service_fn(move |req| {
113                        let shared = svc_shared.clone();
114                        async move { Ok::<_, Infallible>(handle(&shared, id, req).await) }
115                    });
116                    let mut builder = http1::Builder::new();
117                    builder
118                        .timer(TokioTimer::new())
119                        .header_read_timeout(HEADER_TIMEOUT)
120                        .max_headers(64)
121                        .max_buf_size(32 * 1024);
122                    if let Err(e) = builder
123                        .serve_connection(TokioIo::new(stream), service)
124                        .await
125                    {
126                        Log::new(Some(&child.ctx.status_tx))
127                            .debug(format!("SCIM connection {id}: {e}"));
128                    }
129                    child
130                        .ctx
131                        .state
132                        .update_connection_status(server_id, id, ConnectionStatus::Closed)
133                        .await;
134                    let _ = child.ctx.status_tx.send("__UPDATE_UI__".into());
135                })
136                .await;
137        }
138    });
139    ctx.state.register_server_task(server_id, accept).await;
140    Ok(local)
141}
142
143type Reply = Response<Full<Bytes>>;
144
145fn reply(status: u16, body: Option<&Value>, location: Option<String>) -> Reply {
146    let mut r = Response::new(Full::new(Bytes::from(
147        body.map(|b| serde_json::to_vec(b).unwrap_or_default())
148            .unwrap_or_default(),
149    )));
150    *r.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
151    if body.is_some() {
152        r.headers_mut().insert(
153            header::CONTENT_TYPE,
154            header::HeaderValue::from_static(CONTENT_TYPE),
155        );
156    }
157    if let Some(l) = location.and_then(|l| header::HeaderValue::from_str(&l).ok()) {
158        r.headers_mut().insert(header::LOCATION, l);
159    }
160    r
161}
162
163fn error(status: u16, scim_type: Option<&str>, detail: &str) -> Reply {
164    reply(status, Some(&error_body(status, scim_type, detail)), None)
165}
166
167fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
168    let summary = format!("SCIM connection {id} operation={operation} decision={decision}");
169    let log = Log::new(Some(&ctx.status_tx));
170    if decision.starts_with("fail_closed_") || decision == "model_silent" {
171        log.error(summary);
172    } else {
173        log.info(summary);
174    }
175}
176
177async fn handle(shared: &Shared, id: ConnectionId, req: Request<Incoming>) -> Reply {
178    let ctx = &shared.ctx;
179    ctx.state
180        .update_connection_stats(
181            ctx.server_id,
182            id,
183            Some(req.uri().to_string().len() as u64),
184            None,
185            Some(1),
186            None,
187        )
188        .await;
189    let response = route(shared, id, req).await;
190    let sent = hyper::body::Body::size_hint(response.body())
191        .exact()
192        .unwrap_or(0);
193    ctx.state
194        .update_connection_stats(ctx.server_id, id, None, Some(sent), None, Some(1))
195        .await;
196    response
197}
198
199/// Absolute base URL for `meta.location`, from the request's Host.
200fn base_url(shared: &Shared, req: &Request<Incoming>) -> String {
201    let host = req
202        .headers()
203        .get(header::HOST)
204        .and_then(|h| h.to_str().ok())
205        .filter(|h| {
206            !h.is_empty()
207                && h.len() <= 255
208                && h.bytes()
209                    .all(|b| b.is_ascii_alphanumeric() || b".:-[]".contains(&b))
210        })
211        .map(str::to_owned)
212        .unwrap_or_else(|| shared.local.to_string());
213    format!("http://{host}{}", shared.base)
214}
215
216fn query_param(req: &Request<Incoming>, name: &str) -> Option<String> {
217    let q = req.uri().query()?;
218    q.split('&').find_map(|pair| {
219        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
220        (k == name).then(|| {
221            urlencoding::decode(&v.replace('+', " "))
222                .map(|c| c.into_owned())
223                .unwrap_or_default()
224        })
225    })
226}
227
228async fn read_json(req: Request<Incoming>) -> Result<Value, Reply> {
229    let ct = req
230        .headers()
231        .get(header::CONTENT_TYPE)
232        .and_then(|v| v.to_str().ok())
233        .unwrap_or("")
234        .to_ascii_lowercase();
235    if !(ct.starts_with(CONTENT_TYPE) || ct.starts_with("application/json")) {
236        return Err(error(
237            415,
238            None,
239            "Content-Type must be application/scim+json",
240        ));
241    }
242    let body = match tokio::time::timeout(
243        BODY_TIMEOUT,
244        Limited::new(req.into_body(), MAX_BODY_BYTES).collect(),
245    )
246    .await
247    {
248        Ok(Ok(b)) => b.to_bytes(),
249        _ => return Err(error(413, None, "request body unreadable or over 1 MiB")),
250    };
251    match serde_json::from_slice::<Value>(&body) {
252        Ok(v @ Value::Object(_)) if model_bounds::budget_ok(&v) => Ok(v),
253        _ => Err(error(
254            400,
255            Some("invalidSyntax"),
256            "the body is not a JSON object within bounds",
257        )),
258    }
259}
260
261struct ListQuery {
262    filter: Option<(String, Filter)>,
263    sort_by: Option<AttrPath>,
264    descending: bool,
265    start: usize,
266    count: usize,
267    attributes: Vec<AttrPath>,
268    excluded: Vec<AttrPath>,
269}
270
271fn list_query(
272    filter: Option<String>,
273    sort_by: Option<String>,
274    sort_order: Option<String>,
275    start: Option<String>,
276    count: Option<String>,
277    attributes: Option<String>,
278    excluded: Option<String>,
279) -> Result<ListQuery, Reply> {
280    let bad = |t: &str, e: anyhow::Error| error(400, Some(t), &e.to_string());
281    let filter = match filter.filter(|f| !f.trim().is_empty()) {
282        Some(f) => Some((
283            f.clone(),
284            query::parse_filter(&f).map_err(|e| bad("invalidFilter", e))?,
285        )),
286        None => None,
287    };
288    let sort_by = sort_by
289        .filter(|s| !s.is_empty())
290        .map(|s| AttrPath::parse(&s))
291        .transpose()
292        .map_err(|e| bad("invalidValue", e))?;
293    let descending = match sort_order.as_deref() {
294        None | Some("") | Some("ascending") => false,
295        Some("descending") => true,
296        Some(_) => {
297            return Err(error(
298                400,
299                Some("invalidValue"),
300                "sortOrder is ascending or descending",
301            ))
302        }
303    };
304    let num = |v: Option<String>, d: usize| -> Result<usize, Reply> {
305        match v.filter(|v| !v.is_empty()) {
306            None => Ok(d),
307            Some(v) => v.parse::<i64>().map(|n| n.max(0) as usize).map_err(|_| {
308                error(
309                    400,
310                    Some("invalidValue"),
311                    "startIndex and count are integers",
312                )
313            }),
314        }
315    };
316    Ok(ListQuery {
317        filter,
318        sort_by,
319        descending,
320        start: num(start, 1)?.max(1),
321        count: num(count, MAX_RESULTS)?.min(MAX_RESULTS),
322        attributes: query::parse_list(attributes.as_deref()).map_err(|e| bad("invalidValue", e))?,
323        excluded: query::parse_list(excluded.as_deref()).map_err(|e| bad("invalidValue", e))?,
324    })
325}
326
327fn service_provider_config(shared: &Shared, base: &str) -> Value {
328    let schemes = match shared.token {
329        Some(_) => {
330            json!([{"type": "oauthbearertoken", "name": "OAuth Bearer Token", "description": "Authentication with a bearer token (RFC 6750)", "specUri": "https://www.rfc-editor.org/rfc/rfc6750", "primary": true}])
331        }
332        None => json!([]),
333    };
334    json!({
335        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
336        "patch": {"supported": true},
337        "bulk": {"supported": false, "maxOperations": 0, "maxPayloadSize": 0},
338        "filter": {"supported": true, "maxResults": MAX_RESULTS},
339        "changePassword": {"supported": false},
340        "sort": {"supported": true},
341        "etag": {"supported": false},
342        "authenticationSchemes": schemes,
343        "meta": {"resourceType": "ServiceProviderConfig", "location": format!("{base}/ServiceProviderConfig")}
344    })
345}
346
347fn list_response(resources: Vec<Value>, total: usize, start: usize) -> Value {
348    json!({"schemas": [schema::LIST_RESPONSE], "totalResults": total, "startIndex": start, "itemsPerPage": resources.len(), "Resources": resources})
349}
350
351fn authorized(shared: &Shared, req: &Request<Incoming>) -> bool {
352    let Some(expected) = &shared.token else {
353        return true;
354    };
355    let got = req
356        .headers()
357        .get(header::AUTHORIZATION)
358        .and_then(|v| v.to_str().ok())
359        .and_then(|v| v.strip_prefix("Bearer "))
360        .unwrap_or("");
361    got.len() == expected.len()
362        && got
363            .bytes()
364            .zip(expected.bytes())
365            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
366            == 0
367}
368
369async fn route(shared: &Shared, id: ConnectionId, req: Request<Incoming>) -> Reply {
370    let path = req.uri().path().to_owned();
371    let Some(rest) = path
372        .strip_prefix(shared.base.as_str())
373        .filter(|r| r.is_empty() || r.starts_with('/'))
374    else {
375        return error(404, None, "not a SCIM endpoint");
376    };
377    if !authorized(shared, &req) {
378        let mut r = error(401, None, "a valid bearer token is required");
379        r.headers_mut().insert(
380            header::WWW_AUTHENTICATE,
381            header::HeaderValue::from_static("Bearer realm=\"NetGet SCIM\""),
382        );
383        return r;
384    }
385    let base = base_url(shared, &req);
386    let segments: Vec<String> = rest
387        .trim_matches('/')
388        .split('/')
389        .filter(|s| !s.is_empty())
390        .map(|s| {
391            urlencoding::decode(s)
392                .map(|c| c.into_owned())
393                .unwrap_or_default()
394        })
395        .collect();
396    let method = req.method().clone();
397    let get = method == Method::GET;
398    let seg: Vec<&str> = segments.iter().map(String::as_str).collect();
399    match seg.as_slice() {
400        ["ServiceProviderConfig"] if get => {
401            return reply(200, Some(&service_provider_config(shared, &base)), None)
402        }
403        ["ResourceTypes"] if get => {
404            let all: Vec<Value> = schema::RESOURCE_TYPES
405                .iter()
406                .map(|r| schema::resource_type_resource(r, &base))
407                .collect();
408            let n = all.len();
409            return reply(200, Some(&list_response(all, n, 1)), None);
410        }
411        ["ResourceTypes", name] if get => {
412            return match schema::by_name(name) {
413                Some(r) => reply(200, Some(&schema::resource_type_resource(r, &base)), None),
414                None => error(404, None, "no such resource type"),
415            };
416        }
417        ["Schemas"] if get => {
418            let all: Vec<Value> = schema::SCHEMAS
419                .iter()
420                .map(|s| schema::published(s, &base))
421                .collect();
422            let n = all.len();
423            return reply(200, Some(&list_response(all, n, 1)), None);
424        }
425        ["Schemas", urn] if get => {
426            return match schema::schema(urn) {
427                Some(s) => reply(200, Some(&schema::published(s, &base)), None),
428                None => error(404, None, "no such schema"),
429            };
430        }
431        ["ServiceProviderConfig" | "ResourceTypes" | "Schemas", ..] => {
432            return error(405, None, "discovery endpoints are read-only")
433        }
434        ["Bulk"] | ["Me", ..] => return error(501, None, "not implemented by this service"),
435        [] | [".search"] => {
436            let lq = match search_query(&method, req).await {
437                Ok(q) => q,
438                Err(r) => return r,
439            };
440            return list(
441                shared,
442                id,
443                schema::RESOURCE_TYPES.iter().collect(),
444                lq,
445                &base,
446            )
447            .await;
448        }
449        _ => {}
450    }
451    let Some(rt) = seg.first().and_then(|s| schema::by_endpoint(s)) else {
452        return error(404, None, "no such endpoint");
453    };
454    match (seg.len(), seg.get(1).copied()) {
455        (1, _) if get => {
456            let lq = list_query(
457                query_param(&req, "filter"),
458                query_param(&req, "sortBy"),
459                query_param(&req, "sortOrder"),
460                query_param(&req, "startIndex"),
461                query_param(&req, "count"),
462                query_param(&req, "attributes"),
463                query_param(&req, "excludedAttributes"),
464            );
465            match lq {
466                Ok(q) => list(shared, id, vec![rt], q, &base).await,
467                Err(r) => r,
468            }
469        }
470        (2, Some(".search")) if method == Method::POST => match search_query(&method, req).await {
471            Ok(q) => list(shared, id, vec![rt], q, &base).await,
472            Err(r) => r,
473        },
474        (1, _) if method == Method::POST => {
475            let projection = (
476                query_param(&req, "attributes"),
477                query_param(&req, "excludedAttributes"),
478            );
479            let body = match read_json(req).await {
480                Ok(b) => b,
481                Err(r) => return r,
482            };
483            let resource = match incoming_resource(rt, body) {
484                Ok(r) => r,
485                Err(r) => return r,
486            };
487            single(
488                shared,
489                id,
490                rt,
491                "create",
492                None,
493                json!({"resource": resource}),
494                &base,
495                projection,
496            )
497            .await
498        }
499        (1, _) => error(405, None, "use GET or POST on a collection"),
500        (2, Some(rid)) => {
501            let rid = rid.to_owned();
502            if rid.len() > 256 {
503                return error(404, None, "no such resource");
504            }
505            let projection = (
506                query_param(&req, "attributes"),
507                query_param(&req, "excludedAttributes"),
508            );
509            match method {
510                Method::GET => {
511                    single(
512                        shared,
513                        id,
514                        rt,
515                        "get",
516                        Some(&rid),
517                        json!({}),
518                        &base,
519                        projection,
520                    )
521                    .await
522                }
523                Method::DELETE => {
524                    single(
525                        shared,
526                        id,
527                        rt,
528                        "delete",
529                        Some(&rid),
530                        json!({}),
531                        &base,
532                        projection,
533                    )
534                    .await
535                }
536                Method::PUT => {
537                    let body = match read_json(req).await {
538                        Ok(b) => b,
539                        Err(r) => return r,
540                    };
541                    let resource = match incoming_resource(rt, body) {
542                        Ok(r) => r,
543                        Err(r) => return r,
544                    };
545                    single(
546                        shared,
547                        id,
548                        rt,
549                        "replace",
550                        Some(&rid),
551                        json!({"resource": resource}),
552                        &base,
553                        projection,
554                    )
555                    .await
556                }
557                Method::PATCH => {
558                    let body = match read_json(req).await {
559                        Ok(b) => b,
560                        Err(r) => return r,
561                    };
562                    let ops = match patch_operations(&body) {
563                        Ok(o) => o,
564                        Err(r) => return r,
565                    };
566                    single(
567                        shared,
568                        id,
569                        rt,
570                        "patch",
571                        Some(&rid),
572                        json!({"operations": ops}),
573                        &base,
574                        projection,
575                    )
576                    .await
577                }
578                _ => error(405, None, "method not allowed on a resource"),
579            }
580        }
581        _ => error(404, None, "no such endpoint"),
582    }
583}
584
585async fn search_query(method: &Method, req: Request<Incoming>) -> Result<ListQuery, Reply> {
586    if *method == Method::GET {
587        return list_query(
588            query_param(&req, "filter"),
589            query_param(&req, "sortBy"),
590            query_param(&req, "sortOrder"),
591            query_param(&req, "startIndex"),
592            query_param(&req, "count"),
593            query_param(&req, "attributes"),
594            query_param(&req, "excludedAttributes"),
595        );
596    }
597    if *method != Method::POST {
598        return Err(error(405, None, "use POST for .search"));
599    }
600    let body = read_json(req).await?;
601    if !body["schemas"]
602        .as_array()
603        .is_some_and(|s| s.iter().any(|x| x == schema::SEARCH_REQUEST))
604    {
605        return Err(error(
606            400,
607            Some("invalidSyntax"),
608            "a search body declares the SearchRequest schema",
609        ));
610    }
611    let s = |k: &str| {
612        body.get(k).and_then(|v| match v {
613            Value::String(s) => Some(s.clone()),
614            Value::Number(n) => Some(n.to_string()),
615            Value::Array(a) => Some(
616                a.iter()
617                    .filter_map(Value::as_str)
618                    .collect::<Vec<_>>()
619                    .join(","),
620            ),
621            _ => None,
622        })
623    };
624    list_query(
625        s("filter"),
626        s("sortBy"),
627        s("sortOrder"),
628        s("startIndex"),
629        s("count"),
630        s("attributes"),
631        s("excludedAttributes"),
632    )
633}
634
635/// A resource from a create or replace body: schemas must name the type's core schema; id
636/// and meta are read-only and dropped.
637fn incoming_resource(rt: &ResourceType, mut body: Value) -> Result<Value, Reply> {
638    if !body["schemas"].as_array().is_some_and(|s| {
639        s.iter().any(|x| {
640            x.as_str()
641                .is_some_and(|x| x.eq_ignore_ascii_case(rt.schema))
642        })
643    }) {
644        return Err(error(
645            400,
646            Some("invalidSyntax"),
647            &format!("schemas must include {}", rt.schema),
648        ));
649    }
650    if let Some(o) = body.as_object_mut() {
651        o.retain(|k, _| !k.eq_ignore_ascii_case("id") && !k.eq_ignore_ascii_case("meta"));
652    }
653    Ok(body)
654}
655
656fn patch_operations(body: &Value) -> Result<Vec<Value>, Reply> {
657    let bad = |d: String| error(400, Some("invalidSyntax"), &d);
658    if !body["schemas"]
659        .as_array()
660        .is_some_and(|s| s.iter().any(|x| x == schema::PATCH_OP))
661    {
662        return Err(bad(format!("a PATCH body declares {}", schema::PATCH_OP)));
663    }
664    let ops = body
665        .get("Operations")
666        .or_else(|| body.get("operations"))
667        .and_then(Value::as_array)
668        .filter(|o| !o.is_empty() && o.len() <= MAX_PATCH_OPERATIONS)
669        .ok_or_else(|| {
670            bad(format!(
671                "Operations must hold 1..{MAX_PATCH_OPERATIONS} operations"
672            ))
673        })?;
674    ops.iter().map(|o| {
675        let op = o["op"].as_str().map(str::to_ascii_lowercase).filter(|op| matches!(op.as_str(), "add" | "remove" | "replace"))
676            .ok_or_else(|| bad("op must be add, remove or replace".into()))?;
677        let path = match o.get("path").filter(|p| !p.is_null()) {
678            None => None,
679            Some(p) => {
680                let p = p.as_str().ok_or_else(|| bad("path must be a string".into()))?;
681                Some((p.to_owned(), query::PatchPath::parse(p).map_err(|e| error(400, Some("invalidPath"), &e.to_string()))?))
682            }
683        };
684        if op == "remove" && path.is_none() {
685            return Err(error(400, Some("noTarget"), "remove needs a path"));
686        }
687        if op != "remove" && o.get("value").is_none_or(Value::is_null) {
688            return Err(bad(format!("{op} needs a value")));
689        }
690        if op != "remove" && path.is_none() && !o["value"].is_object() {
691            return Err(bad(format!("{op} without a path takes an object value")));
692        }
693        Ok(json!({"op": op, "path": path.as_ref().map(|p| &p.0), "parsed_path": path.as_ref().map(|p| p.1.to_json()), "value": o.get("value")}))
694    }).collect()
695}
696
697/// Ask the handler. Failures are logged and returned as the reply to send.
698async fn ask(
699    shared: &Shared,
700    id: ConnectionId,
701    operation: &str,
702    data: Value,
703) -> Result<Value, Reply> {
704    let ctx = &shared.ctx;
705    let failed = |decision: &str, e: anyhow::Error| {
706        outcome(ctx, id, operation, decision);
707        let failure = crate::utils::wire_failure::WireFailure::classify(&e);
708        error(
709            if failure.is_overloaded() { 503 } else { 500 },
710            None,
711            failure.text(),
712        )
713    };
714    let event = Event::new(&actions::REQUEST_EVENT, data);
715    let result = match call_llm(
716        &ctx.llm_client,
717        &ctx.state,
718        ctx.server_id,
719        Some(id),
720        &event,
721        &actions::ScimProtocol,
722    )
723    .await
724    {
725        Ok(r) => r,
726        Err(e) => return Err(failed("fail_closed_llm_error", e)),
727    };
728    if !result.failures.is_empty() {
729        return Err(failed(
730            "fail_closed_invalid_reply",
731            anyhow::anyhow!("invalid handler answer"),
732        ));
733    }
734    let mut answers = Vec::new();
735    let mut pending = result.protocol_results;
736    while let Some(r) = pending.pop() {
737        match r {
738            ActionResult::Custom { data, .. } => answers.push(data),
739            ActionResult::Multiple(items) => pending.extend(items),
740            _ => {}
741        }
742    }
743    match answers.len() {
744        1 => {
745            let a = answers.remove(0);
746            if a["type"] == "scim_error" {
747                outcome(ctx, id, operation, "model_reject");
748                let status = a["status"].as_u64().unwrap_or(500) as u16;
749                return Err(error(
750                    status,
751                    a["scim_type"].as_str(),
752                    a["detail"].as_str().unwrap_or("refused"),
753                ));
754            }
755            Ok(a)
756        }
757        0 => Err(failed("model_silent", anyhow::anyhow!("no handler answer"))),
758        _ => Err(failed(
759            "fail_closed_invalid_reply",
760            anyhow::anyhow!("more than one answer"),
761        )),
762    }
763}
764
765/// Check a handler resource and write its meta.
766fn finish(
767    rt: &ResourceType,
768    mut r: Value,
769    expected_id: Option<&str>,
770    base: &str,
771) -> anyhow::Result<Value> {
772    let rid = r["id"]
773        .as_str()
774        .filter(|i| !i.is_empty() && i.len() <= 256 && !i.contains('/'))
775        .map(str::to_owned)
776        .ok_or_else(|| anyhow::anyhow!("a resource needs an id without /"))?;
777    if let Some(e) = expected_id {
778        anyhow::ensure!(rid == e, "the answer describes {rid}, not {e}");
779    }
780    let o = r
781        .as_object_mut()
782        .ok_or_else(|| anyhow::anyhow!("a resource is an object"))?;
783    let schemas = o.entry("schemas").or_insert_with(|| json!([rt.schema]));
784    let list = schemas
785        .as_array_mut()
786        .ok_or_else(|| anyhow::anyhow!("schemas must be an array"))?;
787    if !list.iter().any(|s| {
788        s.as_str()
789            .is_some_and(|s| s.eq_ignore_ascii_case(rt.schema))
790    }) {
791        list.insert(0, json!(rt.schema));
792    }
793    // RFC 7643 ยง3: schemas names every schema whose attributes the resource carries, so an
794    // extension URN follows its object, and an emptied extension object goes with its URN.
795    for ext in rt.extensions {
796        let present = schema::member_key(&Value::Object(o.clone()), ext);
797        let keep = present
798            .as_ref()
799            .is_some_and(|k| o[k].as_object().is_some_and(|m| !m.is_empty()));
800        if let (Some(k), false) = (&present, keep) {
801            o.remove(k);
802        }
803        let list = o
804            .get_mut("schemas")
805            .and_then(Value::as_array_mut)
806            .expect("schemas set above");
807        list.retain(|s| !s.as_str().is_some_and(|s| s.eq_ignore_ascii_case(ext)));
808        if keep {
809            list.push(json!(ext));
810        }
811    }
812    let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
813    let meta = o.entry("meta").or_insert_with(|| json!({}));
814    let meta = meta
815        .as_object_mut()
816        .ok_or_else(|| anyhow::anyhow!("meta must be an object"))?;
817    meta.insert("resourceType".into(), json!(rt.name));
818    meta.insert(
819        "location".into(),
820        json!(format!("{base}{}/{rid}", rt.endpoint)),
821    );
822    for k in ["created", "lastModified"] {
823        if !meta.get(k).is_some_and(Value::is_string) {
824            meta.insert(k.into(), json!(now));
825        }
826    }
827    Ok(r)
828}
829
830#[allow(clippy::too_many_arguments)]
831async fn single(
832    shared: &Shared,
833    id: ConnectionId,
834    rt: &ResourceType,
835    operation: &str,
836    rid: Option<&str>,
837    extra: Value,
838    base: &str,
839    projection: (Option<String>, Option<String>),
840) -> Reply {
841    let ctx = &shared.ctx;
842    let (attributes, excluded) = match (
843        query::parse_list(projection.0.as_deref()),
844        query::parse_list(projection.1.as_deref()),
845    ) {
846        (Ok(a), Ok(e)) => (a, e),
847        (Err(e), _) | (_, Err(e)) => return error(400, Some("invalidValue"), &e.to_string()),
848    };
849    let mut data = json!({"operation": operation, "resource_type": rt.name, "id": rid});
850    if let Value::Object(m) = extra {
851        data.as_object_mut().expect("object").extend(m);
852    }
853    let answer = match ask(shared, id, operation, data).await {
854        Ok(a) => a,
855        Err(r) => return r,
856    };
857    let built: anyhow::Result<Reply> =
858        (|| match (answer["type"].as_str().unwrap_or_default(), operation) {
859            ("scim_no_content", "delete" | "patch") => Ok(reply(204, None, None)),
860            ("scim_resource", "get" | "replace" | "patch" | "create") => {
861                let r = finish(rt, answer["resource"].clone(), rid, base)?;
862                let location = r["meta"]["location"].as_str().map(str::to_owned);
863                let body = query::project(&r, rt, &attributes, &excluded);
864                Ok(if operation == "create" {
865                    reply(201, Some(&body), location)
866                } else {
867                    reply(200, Some(&body), None)
868                })
869            }
870            (t, o) => anyhow::bail!("{t} does not answer {o}"),
871        })();
872    match built {
873        Ok(r) => {
874            outcome(ctx, id, operation, "model_answer");
875            r
876        }
877        Err(e) => {
878            outcome(ctx, id, operation, "fail_closed_invalid_reply");
879            Log::new(Some(&ctx.status_tx)).warn(format!("SCIM {operation}: {e}"));
880            error(
881                500,
882                None,
883                crate::utils::wire_failure::WireFailure::Unavailable.text(),
884            )
885        }
886    }
887}
888
889async fn list(
890    shared: &Shared,
891    id: ConnectionId,
892    types: Vec<&ResourceType>,
893    q: ListQuery,
894    base: &str,
895) -> Reply {
896    let ctx = &shared.ctx;
897    let mut all = Vec::new();
898    for rt in &types {
899        let data = json!({
900            "operation": "list",
901            "resource_type": rt.name,
902            "filter": q.filter.as_ref().map(|f| &f.0),
903            "filter_parsed": q.filter.as_ref().map(|f| f.1.to_json()),
904            "sort_by": q.sort_by.as_ref().map(|s| s.to_json()),
905        });
906        let answer = match ask(shared, id, "list", data).await {
907            Ok(a) => a,
908            Err(r) => return r,
909        };
910        if answer["type"] != "scim_resources" {
911            outcome(ctx, id, "list", "fail_closed_invalid_reply");
912            return error(
913                500,
914                None,
915                crate::utils::wire_failure::WireFailure::Unavailable.text(),
916            );
917        }
918        for r in answer["resources"].as_array().cloned().unwrap_or_default() {
919            match finish(rt, r, None, base) {
920                Ok(r)
921                    if q.filter
922                        .as_ref()
923                        .is_none_or(|f| query::matches(&f.1, rt, &r)) =>
924                {
925                    all.push((*rt, r))
926                }
927                Ok(_) => {}
928                Err(e) => {
929                    outcome(ctx, id, "list", "fail_closed_invalid_reply");
930                    Log::new(Some(&ctx.status_tx)).warn(format!("SCIM list: {e}"));
931                    return error(
932                        500,
933                        None,
934                        crate::utils::wire_failure::WireFailure::Unavailable.text(),
935                    );
936                }
937            }
938        }
939    }
940    if let (Some(path), [rt]) = (&q.sort_by, types.as_slice()) {
941        let mut resources: Vec<Value> = all.drain(..).map(|(_, r)| r).collect();
942        query::sort(&mut resources, rt, path, q.descending);
943        all = resources.into_iter().map(|r| (*rt, r)).collect();
944    }
945    let total = all.len();
946    let page: Vec<Value> = all
947        .into_iter()
948        .skip(q.start - 1)
949        .take(q.count)
950        .map(|(rt, r)| query::project(&r, rt, &q.attributes, &q.excluded))
951        .collect();
952    outcome(ctx, id, "list", "model_answer");
953    reply(200, Some(&list_response(page, total, q.start)), None)
954}