SCIM: users, groups, queries, PATCH, Bulk and cursors at /scim/v2, over x:Account (SCIM-1 to SCIM-57)
Every SCIM operation becomes the x:Account get, query or set JMAP makes, as the service principal, so permissions, tenant scope and limits, address uniqueness and account destruction are enforced in one place. Discovery is anonymous; everything else takes an API key as a bearer token and nothing else. Domains open to SCIM carry a flag in the domain cache. Filters take eq and and, answered from the account indexes, with unindexed attributes checked on at most 200 candidates. Cursors are stateless, HMAC-sealed under the server key. PATCH applies to the resource in memory and saves it as a PUT, so it is all or nothing. Groups get an address from their display name on the principal's domain; membership is written on each user. Every write emits one of five new scim.* events (ids 637 to 641), also added to the packaged schema. The helpers the surviving SCIM suites import are rebuilt from the spec; scim_tests runs the new acceptance suite and the surviving tenant isolation suite, and both pass.
This commit is contained in:
+325
-1
@@ -1,5 +1,329 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
|
||||
//! service provider: an identity provider pushes users and groups to
|
||||
//! `/scim/v2`, and each request becomes the same `x:Account` reads and
|
||||
//! writes JMAP makes, so permissions, tenancy, address uniqueness and quotas
|
||||
//! are enforced in one place. The HTTP crate authenticates the caller; this
|
||||
//! crate routes and translates.
|
||||
|
||||
pub mod bulk;
|
||||
pub mod context;
|
||||
pub mod cursor;
|
||||
pub mod discovery;
|
||||
pub mod groups;
|
||||
pub mod patch;
|
||||
pub mod query;
|
||||
pub mod resource;
|
||||
pub mod users;
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use context::Ctx;
|
||||
use http_proto::{HttpResponse, HttpSessionData};
|
||||
use hyper::{HeaderMap, Method, StatusCode};
|
||||
use scim_proto::{CONTENT_TYPE, ScimError};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The largest body accepted, `/Bulk` included (SCIM-51).
|
||||
pub const MAX_PAYLOAD: usize = 1024 * 1024;
|
||||
/// `/Bulk` operations per request (SCIM-51).
|
||||
pub const MAX_OPERATIONS: usize = 1000;
|
||||
/// The most results a page, a filter or a group's members may hold (SCIM-4).
|
||||
pub const MAX_RESULTS: usize = 200;
|
||||
/// A page's size when `count` isn't given (SCIM-48).
|
||||
pub const DEFAULT_PAGE_SIZE: usize = 100;
|
||||
/// How long a cursor stays good, in seconds (SCIM-49).
|
||||
pub const CURSOR_TIMEOUT: u64 = 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceKind {
|
||||
User,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl ResourceKind {
|
||||
pub fn endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => "Users",
|
||||
ResourceKind::Group => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => "User",
|
||||
ResourceKind::Group => "Group",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => scim_proto::SCHEMA_USER,
|
||||
ResourceKind::Group => scim_proto::SCHEMA_GROUP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a path and method ask for (SCIM-2, "Interfaces").
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
Options,
|
||||
ServiceProviderConfig,
|
||||
ResourceTypes(Option<String>),
|
||||
Schemas(Option<String>),
|
||||
Me,
|
||||
List(ResourceKind),
|
||||
Create(ResourceKind),
|
||||
Search(Option<ResourceKind>),
|
||||
Get(ResourceKind, String),
|
||||
Replace(ResourceKind, String),
|
||||
Modify(ResourceKind, String),
|
||||
Delete(ResourceKind, String),
|
||||
Bulk,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// Routes the path segments after `/scim/v2`.
|
||||
pub fn parse(method: &Method, segments: &[String]) -> Result<Route, ScimResponse> {
|
||||
if method == Method::OPTIONS {
|
||||
return Ok(Route::Options);
|
||||
}
|
||||
let not_allowed = |allow: &str| Err(ScimResponse::method_not_allowed(allow));
|
||||
let segments = segments
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let kind = |name: &str| {
|
||||
if name.eq_ignore_ascii_case("Users") {
|
||||
Some(ResourceKind::User)
|
||||
} else if name.eq_ignore_ascii_case("Groups") {
|
||||
Some(ResourceKind::Group)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match segments.as_slice() {
|
||||
[name] if name.eq_ignore_ascii_case("ServiceProviderConfig") => match *method {
|
||||
Method::GET => Ok(Route::ServiceProviderConfig),
|
||||
_ => not_allowed("GET, OPTIONS"),
|
||||
},
|
||||
[name, rest @ ..] if name.eq_ignore_ascii_case("ResourceTypes") && rest.len() <= 1 => {
|
||||
match *method {
|
||||
Method::GET => Ok(Route::ResourceTypes(rest.first().map(|s| s.to_string()))),
|
||||
_ => not_allowed("GET, OPTIONS"),
|
||||
}
|
||||
}
|
||||
[name, rest @ ..] if name.eq_ignore_ascii_case("Schemas") && rest.len() <= 1 => {
|
||||
match *method {
|
||||
Method::GET => Ok(Route::Schemas(rest.first().map(|s| s.to_string()))),
|
||||
_ => not_allowed("GET, OPTIONS"),
|
||||
}
|
||||
}
|
||||
[name, ..] if name.eq_ignore_ascii_case("Me") => Ok(Route::Me),
|
||||
[name] if name.eq_ignore_ascii_case("Bulk") => match *method {
|
||||
Method::POST => Ok(Route::Bulk),
|
||||
_ => not_allowed("POST, OPTIONS"),
|
||||
},
|
||||
[".search"] => match *method {
|
||||
Method::POST => Ok(Route::Search(None)),
|
||||
_ => not_allowed("POST, OPTIONS"),
|
||||
},
|
||||
[name] if kind(name).is_some() => {
|
||||
let kind = kind(name).unwrap();
|
||||
match *method {
|
||||
Method::GET => Ok(Route::List(kind)),
|
||||
Method::POST => Ok(Route::Create(kind)),
|
||||
_ => not_allowed("GET, POST, OPTIONS"),
|
||||
}
|
||||
}
|
||||
[name, ".search"] if kind(name).is_some() => match *method {
|
||||
Method::POST => Ok(Route::Search(kind(name))),
|
||||
_ => not_allowed("POST, OPTIONS"),
|
||||
},
|
||||
[name, id] if kind(name).is_some() => {
|
||||
let kind = kind(name).unwrap();
|
||||
let id = id.to_string();
|
||||
match *method {
|
||||
Method::GET => Ok(Route::Get(kind, id)),
|
||||
Method::PUT => Ok(Route::Replace(kind, id)),
|
||||
Method::PATCH => Ok(Route::Modify(kind, id)),
|
||||
Method::DELETE => Ok(Route::Delete(kind, id)),
|
||||
_ => not_allowed("GET, PUT, PATCH, DELETE, OPTIONS"),
|
||||
}
|
||||
}
|
||||
_ => Err(ScimResponse::error(ScimError::not_found(
|
||||
"There is no such SCIM endpoint",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery, `OPTIONS` and `/Me` need no credential (SCIM-2, SCIM-3).
|
||||
pub fn is_anonymous(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Route::Options
|
||||
| Route::ServiceProviderConfig
|
||||
| Route::ResourceTypes(_)
|
||||
| Route::Schemas(_)
|
||||
| Route::Me
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A SCIM answer, turned into an HTTP response at the edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScimResponse {
|
||||
pub status: u16,
|
||||
pub body: Option<Value>,
|
||||
pub headers: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
impl ScimResponse {
|
||||
pub fn json(status: u16, body: Value) -> Self {
|
||||
ScimResponse {
|
||||
status,
|
||||
body: Some(body),
|
||||
headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty(status: u16) -> Self {
|
||||
ScimResponse {
|
||||
status,
|
||||
body: None,
|
||||
headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(error: ScimError) -> Self {
|
||||
let mut response = ScimResponse::json(error.status, error.to_json());
|
||||
if error.status == 401 {
|
||||
response.headers.push((
|
||||
"WWW-Authenticate",
|
||||
"Bearer realm=\"INBUXA SCIM\"".to_string(),
|
||||
));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn method_not_allowed(allow: &str) -> Self {
|
||||
let mut response = ScimResponse::error(ScimError::new(
|
||||
405,
|
||||
format!("This endpoint accepts {allow}"),
|
||||
));
|
||||
response.headers.push(("Allow", allow.to_string()));
|
||||
response
|
||||
}
|
||||
|
||||
pub fn with_header(mut self, name: &'static str, value: String) -> Self {
|
||||
self.headers.push((name, value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_http_response(self) -> HttpResponse {
|
||||
let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut response = HttpResponse::new(status);
|
||||
for (name, value) in self.headers {
|
||||
response = response.with_header(name, value);
|
||||
}
|
||||
match self.body {
|
||||
Some(body) => response
|
||||
.with_content_type(CONTENT_TYPE)
|
||||
.with_text_body(body.to_string()),
|
||||
None => response,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScimError> for ScimResponse {
|
||||
fn from(error: ScimError) -> Self {
|
||||
ScimResponse::error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// A request that has passed authentication.
|
||||
pub struct ScimRequest<'x> {
|
||||
pub route: Route,
|
||||
pub query: Option<&'x str>,
|
||||
pub headers: &'x HeaderMap,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An internal failure as a SCIM answer. Details stay in the log.
|
||||
pub fn server_error(err: trc::Error) -> ScimError {
|
||||
trc::error!(err.clone().details("SCIM request failed"));
|
||||
ScimError::new(500, "The request couldn't be completed")
|
||||
}
|
||||
|
||||
/// Answers an anonymous route (SCIM-2, SCIM-3).
|
||||
pub fn handle_anonymous(server: &Server, route: &Route, query: Option<&str>) -> ScimResponse {
|
||||
let base = context::base_url(server);
|
||||
if query.is_some_and(|query| {
|
||||
query.split('&').any(|pair| {
|
||||
pair.split('=')
|
||||
.next()
|
||||
.is_some_and(|k| k.eq_ignore_ascii_case("filter"))
|
||||
})
|
||||
}) && !matches!(route, Route::Options | Route::Me)
|
||||
{
|
||||
return ScimError::forbidden("Discovery endpoints don't take a filter").into();
|
||||
}
|
||||
match route {
|
||||
Route::Options => ScimResponse::empty(204),
|
||||
Route::Me => ScimError::new(
|
||||
501,
|
||||
"/Me isn't supported: the caller is a service account, not a provisioned user",
|
||||
)
|
||||
.into(),
|
||||
Route::ServiceProviderConfig => {
|
||||
ScimResponse::json(200, discovery::service_provider_config(&base))
|
||||
}
|
||||
Route::ResourceTypes(id) => discovery::resource_types(&base, id.as_deref()),
|
||||
Route::Schemas(id) => discovery::schemas(&base, id.as_deref()),
|
||||
_ => ScimError::not_found("There is no such SCIM endpoint").into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers an authenticated route.
|
||||
pub async fn handle(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
request: ScimRequest<'_>,
|
||||
) -> ScimResponse {
|
||||
let ctx = match Ctx::new(server, access_token, session).await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => return err.into(),
|
||||
};
|
||||
let ScimRequest {
|
||||
route,
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
} = request;
|
||||
let result = match &route {
|
||||
Route::List(kind) => query::list(&ctx, *kind, query).await,
|
||||
Route::Search(kind) => match resource::parse_body(&body) {
|
||||
Ok(body) => query::search(&ctx, *kind, &body).await,
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
Route::Bulk => bulk::bulk(&ctx, &body).await,
|
||||
Route::Create(kind)
|
||||
| Route::Get(kind, _)
|
||||
| Route::Replace(kind, _)
|
||||
| Route::Modify(kind, _)
|
||||
| Route::Delete(kind, _) => {
|
||||
resource::dispatch(&ctx, *kind, &route, query, headers, &body).await
|
||||
}
|
||||
_ => Err(ScimError::not_found("There is no such SCIM endpoint")),
|
||||
};
|
||||
match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => err.into(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user