| 1 | //! SCIM 2.0 for a workspace's identity provider, at |
| 2 | //! `/scim/v2/{workspace}/…`: the provider assigns and unassigns people, |
| 3 | //! with the workspace's SCIM token as its bearer token, which is not a g1t |
| 4 | //! access token. Identity checks the token and answers everything |
| 5 | //! (services/identity/src/sso/provisioning.rs); this passes the request on |
| 6 | //! and writes the answer as `application/scim+json`. |
| 7 | |
| 8 | use g1t_contracts::sso::{ScimAnswer, ScimRequestArgs}; |
| 9 | use serde_json::json; |
| 10 | use worker::{Request, Response, Result}; |
| 11 | |
| 12 | use crate::operations::Services; |
| 13 | |
| 14 | /// The workspace and the rest of a path after `/scim/v2/`. |
| 15 | pub fn split(rest: &str) -> Option<(String, String)> { |
| 16 | let (workspace, path) = match rest.split_once('/') { |
| 17 | Some((workspace, path)) => (workspace, format!("/{path}")), |
| 18 | None => (rest, "/".to_owned()), |
| 19 | }; |
| 20 | let workspace = workspace.trim().to_lowercase(); |
| 21 | // The workspace's id (`wsp_…`), or for an older setup its name. |
| 22 | let valid = !workspace.is_empty() && workspace.len() <= 64 && workspace.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); |
| 23 | valid.then_some((workspace, path)) |
| 24 | } |
| 25 | |
| 26 | fn reply(status: u16, body: Option<serde_json::Value>) -> Result<Response> { |
| 27 | let mut response = match body { |
| 28 | Some(body) => Response::from_json(&body)?, |
| 29 | None => Response::empty()?, |
| 30 | } |
| 31 | .with_status(status); |
| 32 | if status != 204 { |
| 33 | response.headers_mut().set("content-type", "application/scim+json")?; |
| 34 | } |
| 35 | response.headers_mut().set("cache-control", "no-store")?; |
| 36 | Ok(response) |
| 37 | } |
| 38 | |
| 39 | pub async fn handle(mut request: Request, services: &Services, method: &str, rest: &str) -> Result<Response> { |
| 40 | let Some((workspace, path)) = split(rest) else { |
| 41 | return reply(404, Some(json!({ "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "404", "detail": "Name the workspace: /scim/v2/{workspace}/Users." }))); |
| 42 | }; |
| 43 | let query = request.url()?.query().map(str::to_owned); |
| 44 | let body = if matches!(method, "POST" | "PUT" | "PATCH") { Some(request.text().await?) } else { None }; |
| 45 | let base = format!("{}/scim/v2/{workspace}", services.addresses.api); |
| 46 | let args = ScimRequestArgs { |
| 47 | workspace, |
| 48 | token: crate::toolkit::bearer(&request), |
| 49 | method: method.to_owned(), |
| 50 | path, |
| 51 | query, |
| 52 | body, |
| 53 | base, |
| 54 | }; |
| 55 | let answer: ScimAnswer = g1t_kit::call(&services.identity, "scim_request", &args).await?; |
| 56 | reply(answer.status, answer.body) |
| 57 | } |
| 58 | |
| 59 | #[cfg(test)] |
| 60 | mod tests { |
| 61 | use super::*; |
| 62 | |
| 63 | #[test] |
| 64 | fn the_workspace_comes_first() { |
| 65 | assert_eq!(split("acme/Users/scu_1"), Some(("acme".into(), "/Users/scu_1".into()))); |
| 66 | assert_eq!(split("Acme/Users"), Some(("acme".into(), "/Users".into()))); |
| 67 | assert_eq!(split("acme"), Some(("acme".into(), "/".into()))); |
| 68 | assert_eq!(split("/Users"), None); |
| 69 | assert_eq!(split("ac me/Users"), None); |
| 70 | assert_eq!(split("wsp_01m54abc/Users"), Some(("wsp_01m54abc".into(), "/Users".into()))); |
| 71 | } |
| 72 | } |