1pub 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
199fn 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
635fn 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
697async 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
765fn 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 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}