Browse Source

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 <noreply@anthropic.com>
pull/7443/head
David Croft 3 days ago
parent
commit
a99d05e60a
  1. 4
      src/api/scim/discovery.rs
  2. 45
      src/api/scim/mod.rs

4
src/api/scim/discovery.rs

@ -19,7 +19,7 @@ pub fn routes() -> Vec<Route> {
routes![service_provider_config, resource_types, schemas] 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 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"; 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", "documentationUri": "https://github.com/croftinator/vaultwarden-scim-v2",
"patch": { "supported": true }, "patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }, "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 }, "changePassword": { "supported": false },
"sort": { "supported": false }, "sort": { "supported": false },
"etag": { "supported": false }, "etag": { "supported": false },

45
src/api/scim/mod.rs

@ -28,9 +28,27 @@ use rocket::{
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use crate::db::models::{DeviceType, OrganizationId};
pub use error::ScimError; pub use error::ScimError;
pub use manage::routes as manage_routes; 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<Route> { pub fn routes() -> Vec<Route> {
let mut routes = discovery::routes(); let mut routes = discovery::routes();
routes.append(&mut users::routes()); routes.append(&mut users::routes());
@ -38,6 +56,31 @@ pub fn routes() -> Vec<Route> {
routes 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<i64>, count: Option<i64>) -> (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 // A JSON body limited by the "scim" size limit. Accepts both
// application/scim+json (what Entra sends) and application/json; rocket's // application/scim+json (what Entra sends) and application/json; rocket's
// own Json<T> would reject the former. // own Json<T> would reject the former.
@ -48,7 +91,7 @@ impl<'r, T: DeserializeOwned> FromData<'r> for ScimJson<T> {
type Error = ScimError; type Error = ScimError;
async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { 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 { let bytes = match data.open(limit).into_bytes().await {
Ok(bytes) if bytes.is_complete() => bytes.into_inner(), Ok(bytes) if bytes.is_complete() => bytes.into_inner(),
Ok(_) => return DataOutcome::Error((Status::PayloadTooLarge, ScimError::payload_too_large())), Ok(_) => return DataOutcome::Error((Status::PayloadTooLarge, ScimError::payload_too_large())),

Loading…
Cancel
Save