From a99d05e60ac80d8561d75ee7ca107aef987f3f0f Mon Sep 17 00:00:00 2001 From: David Croft Date: Sun, 19 Jul 2026 21:27:55 +1000 Subject: [PATCH] refactor(scim): hoist shared helpers into scim mod Move the SCIM actor constants, page-size caps, body limit, pagination clamp, ListResponse builder, and resource-location helper into scim/mod.rs so users.rs, groups.rs, discovery.rs, and main.rs share one definition instead of re-inlining literals. Export LIST_RESPONSE_URN and reference SCIM_MAX_RESULTS from ServiceProviderConfig so the advertised filter cap and the enforced cap cannot drift. Co-Authored-By: Claude Fable 5 --- src/api/scim/discovery.rs | 4 ++-- src/api/scim/mod.rs | 45 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/api/scim/discovery.rs b/src/api/scim/discovery.rs index fdef9ac8..f4382d03 100644 --- a/src/api/scim/discovery.rs +++ b/src/api/scim/discovery.rs @@ -19,7 +19,7 @@ pub fn routes() -> Vec { routes![service_provider_config, resource_types, schemas] } -const LIST_RESPONSE_URN: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; +pub(crate) const LIST_RESPONSE_URN: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; pub const USER_SCHEMA_URN: &str = "urn:ietf:params:scim:schemas:core:2.0:User"; pub const GROUP_SCHEMA_URN: &str = "urn:ietf:params:scim:schemas:core:2.0:Group"; @@ -45,7 +45,7 @@ fn service_provider_config(token: ScimToken) -> ScimResponse { "documentationUri": "https://github.com/croftinator/vaultwarden-scim-v2", "patch": { "supported": true }, "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }, - "filter": { "supported": true, "maxResults": 200 }, + "filter": { "supported": true, "maxResults": crate::api::scim::SCIM_MAX_RESULTS }, "changePassword": { "supported": false }, "sort": { "supported": false }, "etag": { "supported": false }, diff --git a/src/api/scim/mod.rs b/src/api/scim/mod.rs index d3aecd50..8e891ac4 100644 --- a/src/api/scim/mod.rs +++ b/src/api/scim/mod.rs @@ -28,9 +28,27 @@ use rocket::{ use serde::de::DeserializeOwned; use serde_json::Value; +use crate::db::models::{DeviceType, OrganizationId}; + pub use error::ScimError; pub use manage::routes as manage_routes; +// Synthetic acting user recorded in the org event log for SCIM-driven +// changes, following the ACTING_ADMIN_USER precedent in the admin panel. +pub(crate) const SCIM_ACTOR: &str = "vaultwarden-scim-00000-000000000000"; +// Device type recorded for SCIM events, same as the admin panel actor. +pub(crate) const SCIM_DEVICE_TYPE: i32 = DeviceType::UnknownBrowser as i32; + +// Page-size cap advertised in ServiceProviderConfig (filter.maxResults) and +// enforced by every list endpoint; keep the two in sync through this constant. +pub(crate) const SCIM_MAX_RESULTS: usize = 200; +pub(crate) const SCIM_DEFAULT_PAGE_SIZE: i64 = 100; + +// The "scim" data limit: registered in main.rs and used as the fallback when +// the figment carries no limit by that name (e.g. a test rocket built from +// Config::default()). +pub const SCIM_BODY_LIMIT: rocket::data::ByteUnit = rocket::data::ByteUnit::Kibibyte(512); + pub fn routes() -> Vec { let mut routes = discovery::routes(); routes.append(&mut users::routes()); @@ -38,6 +56,31 @@ pub fn routes() -> Vec { routes } +// Clamps SCIM pagination parameters: startIndex is 1-based (RFC 7644 +// section 3.4.2.4; zero, negative, and absent all become 1) and a negative +// count collapses to 0 while anything above the cap is truncated. +pub(crate) fn page_bounds(start_index: Option, count: Option) -> (usize, usize) { + let start_index = usize::try_from(start_index.unwrap_or(1)).unwrap_or(1).max(1); + let count = usize::try_from(count.unwrap_or(SCIM_DEFAULT_PAGE_SIZE)).unwrap_or(0).min(SCIM_MAX_RESULTS); + (start_index, count) +} + +pub(crate) fn list_response(total: usize, start_index: usize, resources: &[Value]) -> ScimResponse { + ScimResponse::ok(json!({ + "schemas": [discovery::LIST_RESPONSE_URN], + "totalResults": total, + "itemsPerPage": resources.len(), + "startIndex": start_index, + "Resources": resources, + })) +} + +// Canonical location URL for a Users/Groups resource, used for both the +// Location header and meta.location. +pub(crate) fn resource_location(org_uuid: &OrganizationId, resource_type: &str, id: &dyn std::fmt::Display) -> String { + format!("{}/scim/v2/{org_uuid}/{resource_type}/{id}", crate::CONFIG.domain()) +} + // A JSON body limited by the "scim" size limit. Accepts both // application/scim+json (what Entra sends) and application/json; rocket's // own Json would reject the former. @@ -48,7 +91,7 @@ impl<'r, T: DeserializeOwned> FromData<'r> for ScimJson { type Error = ScimError; async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { - let limit = request.limits().get("scim").unwrap_or_else(|| rocket::data::ByteUnit::Kibibyte(512)); + let limit = request.limits().get("scim").unwrap_or(SCIM_BODY_LIMIT); let bytes = match data.open(limit).into_bytes().await { Ok(bytes) if bytes.is_complete() => bytes.into_inner(), Ok(_) => return DataOutcome::Error((Status::PayloadTooLarge, ScimError::payload_too_large())),