diff --git a/Cargo.lock b/Cargo.lock index 0715098c..61c98019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,6 +1254,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctor" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -3038,6 +3048,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-section" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -5350,6 +5372,13 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "test-support" +version = "0.1.0" +dependencies = [ + "ctor", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5955,6 +5984,7 @@ dependencies = [ "subtle", "svg-hush", "syslog", + "test-support", "time", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 909570e7..df4288a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ repository = "https://github.com/dani-garcia/vaultwarden" publish = false [workspace] -members = ["macros"] +members = ["macros", "test-support"] [package] name = "vaultwarden" @@ -397,3 +397,6 @@ missing_panics_doc = "allow" [lints] workspace = true + +[dev-dependencies] +test-support = { path = "test-support" } diff --git a/migrations/mysql/2026-07-19-000000_add_scim_api_key/down.sql b/migrations/mysql/2026-07-19-000000_add_scim_api_key/down.sql new file mode 100644 index 00000000..368b7e3b --- /dev/null +++ b/migrations/mysql/2026-07-19-000000_add_scim_api_key/down.sql @@ -0,0 +1 @@ +DROP TABLE scim_api_key; diff --git a/migrations/mysql/2026-07-19-000000_add_scim_api_key/up.sql b/migrations/mysql/2026-07-19-000000_add_scim_api_key/up.sql new file mode 100644 index 00000000..ae42e2c6 --- /dev/null +++ b/migrations/mysql/2026-07-19-000000_add_scim_api_key/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE scim_api_key ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL UNIQUE REFERENCES organizations(uuid), + key_hash VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL, + revision_date DATETIME NOT NULL +); diff --git a/migrations/postgresql/2026-07-19-000000_add_scim_api_key/down.sql b/migrations/postgresql/2026-07-19-000000_add_scim_api_key/down.sql new file mode 100644 index 00000000..368b7e3b --- /dev/null +++ b/migrations/postgresql/2026-07-19-000000_add_scim_api_key/down.sql @@ -0,0 +1 @@ +DROP TABLE scim_api_key; diff --git a/migrations/postgresql/2026-07-19-000000_add_scim_api_key/up.sql b/migrations/postgresql/2026-07-19-000000_add_scim_api_key/up.sql new file mode 100644 index 00000000..4889eded --- /dev/null +++ b/migrations/postgresql/2026-07-19-000000_add_scim_api_key/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE scim_api_key ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL UNIQUE REFERENCES organizations(uuid), + key_hash TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL, + revision_date TIMESTAMP NOT NULL +); diff --git a/migrations/sqlite/2026-07-19-000000_add_scim_api_key/down.sql b/migrations/sqlite/2026-07-19-000000_add_scim_api_key/down.sql new file mode 100644 index 00000000..368b7e3b --- /dev/null +++ b/migrations/sqlite/2026-07-19-000000_add_scim_api_key/down.sql @@ -0,0 +1 @@ +DROP TABLE scim_api_key; diff --git a/migrations/sqlite/2026-07-19-000000_add_scim_api_key/up.sql b/migrations/sqlite/2026-07-19-000000_add_scim_api_key/up.sql new file mode 100644 index 00000000..9b717c30 --- /dev/null +++ b/migrations/sqlite/2026-07-19-000000_add_scim_api_key/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE scim_api_key ( + uuid TEXT NOT NULL PRIMARY KEY, + org_uuid TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + FOREIGN KEY(org_uuid) REFERENCES organizations(uuid) +); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 2ea8ab21..1cd42ccb 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -47,6 +47,7 @@ pub fn routes() -> Vec { routes.append(&mut two_factor::routes()); routes.append(&mut sends::routes()); routes.append(&mut public::routes()); + routes.append(&mut crate::api::scim::manage_routes()); routes.append(&mut eq_domains_routes); routes.append(&mut hibp_routes); routes.append(&mut meta_routes); diff --git a/src/api/mod.rs b/src/api/mod.rs index 05c4215d..930d5b47 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ mod icons; mod identity; mod notifications; mod push; +pub mod scim; mod web; use rocket::serde::json::Json; @@ -28,6 +29,8 @@ pub use crate::api::{ push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, unregister_push_device, }, + scim::catchers as scim_catchers, + scim::routes as scim_routes, web::catchers as web_catchers, web::routes as web_routes, web::static_files, diff --git a/src/api/scim/discovery.rs b/src/api/scim/discovery.rs new file mode 100644 index 00000000..fdef9ac8 --- /dev/null +++ b/src/api/scim/discovery.rs @@ -0,0 +1,109 @@ +// +// SCIM discovery endpoints, RFC 7643 section 5 and RFC 7644 section 4. +// +// Static metadata, served behind the ScimToken guard like everything else +// under /scim. Entra ID does not require these for Test Connection (that only +// needs a 200 ListResponse on a userName filter), but other SCIM clients read +// them, and they are cheap. +// +use rocket::Route; +use serde_json::Value; + +use crate::{ + CONFIG, + api::scim::{ScimResponse, guard::ScimToken}, + db::models::OrganizationId, +}; + +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 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"; + +fn scim_base(org_id: &OrganizationId) -> String { + format!("{}/scim/v2/{org_id}", CONFIG.domain()) +} + +fn list_response(resources: &[Value]) -> Value { + json!({ + "schemas": [LIST_RESPONSE_URN], + "totalResults": resources.len(), + "itemsPerPage": resources.len(), + "startIndex": 1, + "Resources": resources, + }) +} + +#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are taken by value")] +#[get("/v2/<_>/ServiceProviderConfig")] +fn service_provider_config(token: ScimToken) -> ScimResponse { + ScimResponse::ok(json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "documentationUri": "https://github.com/croftinator/vaultwarden-scim-v2", + "patch": { "supported": true }, + "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }, + "filter": { "supported": true, "maxResults": 200 }, + "changePassword": { "supported": false }, + "sort": { "supported": false }, + "etag": { "supported": false }, + "authenticationSchemes": [{ + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Static bearer token generated per organization", + "primary": true, + }], + "meta": { + "resourceType": "ServiceProviderConfig", + "location": format!("{}/ServiceProviderConfig", scim_base(&token.org_uuid)), + }, + })) +} + +#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are taken by value")] +#[get("/v2/<_>/ResourceTypes")] +fn resource_types(token: ScimToken) -> ScimResponse { + let base = scim_base(&token.org_uuid); + ScimResponse::ok(list_response(&[ + json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/Users", + "description": "Organization member", + "schema": USER_SCHEMA_URN, + "meta": { "resourceType": "ResourceType", "location": format!("{base}/ResourceTypes/User") }, + }), + json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "description": "Organization group", + "schema": GROUP_SCHEMA_URN, + "meta": { "resourceType": "ResourceType", "location": format!("{base}/ResourceTypes/Group") }, + }), + ])) +} + +#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are taken by value")] +#[get("/v2/<_>/Schemas")] +fn schemas(token: ScimToken) -> ScimResponse { + let base = scim_base(&token.org_uuid); + ScimResponse::ok(list_response(&[ + json!({ + "id": USER_SCHEMA_URN, + "name": "User", + "description": "SCIM core User. Supported attributes: userName, externalId, name, displayName, emails, active.", + "meta": { "resourceType": "Schema", "location": format!("{base}/Schemas/{USER_SCHEMA_URN}") }, + }), + json!({ + "id": GROUP_SCHEMA_URN, + "name": "Group", + "description": "SCIM core Group. Supported attributes: displayName, externalId, members.", + "meta": { "resourceType": "Schema", "location": format!("{base}/Schemas/{GROUP_SCHEMA_URN}") }, + }), + ])) +} diff --git a/src/api/scim/error.rs b/src/api/scim/error.rs new file mode 100644 index 00000000..17c70f5b --- /dev/null +++ b/src/api/scim/error.rs @@ -0,0 +1,93 @@ +// +// SCIM error envelope, RFC 7644 section 3.12. +// +// Every error leaving the /scim mount must use this shape. Auth failures are +// deliberately uniform: the guard logs the specific cause server-side and the +// caller always receives the same 401 body, so responses carry no signal about +// which check failed. +// +use std::io::Cursor; + +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{self, Responder, Response}, +}; + +pub const SCIM_ERROR_URN: &str = "urn:ietf:params:scim:api:messages:2.0:Error"; + +#[derive(Debug)] +pub struct ScimError { + pub status: Status, + pub scim_type: Option<&'static str>, + pub detail: String, +} + +impl ScimError { + pub fn unauthorized() -> Self { + Self { + status: Status::Unauthorized, + scim_type: None, + detail: String::from("Unauthorized"), + } + } + + pub fn too_many_requests() -> Self { + Self { + status: Status::TooManyRequests, + scim_type: None, + detail: String::from("Too many requests"), + } + } + + pub fn not_found() -> Self { + Self { + status: Status::NotFound, + scim_type: None, + detail: String::from("Resource not found"), + } + } + + pub fn bad_request(scim_type: &'static str, detail: &str) -> Self { + Self { + status: Status::BadRequest, + scim_type: Some(scim_type), + detail: String::from(detail), + } + } + + pub fn internal() -> Self { + Self { + status: Status::InternalServerError, + scim_type: None, + detail: String::from("Internal server error"), + } + } + + fn body(&self) -> String { + let mut body = json!({ + "schemas": [SCIM_ERROR_URN], + "status": self.status.code.to_string(), + "detail": self.detail, + }); + if let Some(scim_type) = self.scim_type { + body["scimType"] = json!(scim_type); + } + body.to_string() + } +} + +pub fn scim_content_type() -> ContentType { + ContentType::new("application", "scim+json") +} + +impl Responder<'_, 'static> for ScimError { + fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { + let body = self.body(); + Response::build() + .status(self.status) + .header(scim_content_type()) + .sized_body(Some(body.len()), Cursor::new(body)) + .ok() + } +} diff --git a/src/api/scim/guard.rs b/src/api/scim/guard.rs new file mode 100644 index 00000000..373f1279 --- /dev/null +++ b/src/api/scim/guard.rs @@ -0,0 +1,142 @@ +// +// Request guard for the /scim/v2/ endpoints. +// +// The credential is a per-organization static bearer token of the form +// scim_v1.. +// as configured in the IdP (Entra ID sends a static "Secret Token"; it cannot +// drive an OAuth flow, which rules out the short-lived organization api-key +// JWT used by /api/public). Only the sha256 digest of the secret is stored. +// +// Failure behaviour: every rejection logs its specific cause via err_handler! +// (target "auth", server log only) and surfaces to the caller as a bare 401 +// that the scim catcher turns into one fixed SCIM error body. Rate limiting +// runs before any parsing so unauthenticated floods are cut off first. +// +use rocket::{ + Request, + http::Status, + request::{FromRequest, Outcome}, +}; + +use crate::{ + CONFIG, + auth::ClientIp, + db::{ + DbConn, + models::{OrganizationId, ScimApiKey}, + }, + ratelimit, +}; + +// Digest compared when no key row exists, so a miss costs the same ct_eq as a +// hit. The DB lookup itself still dominates timing; this is hygiene, not a +// timing-safety guarantee. +const DUMMY_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +// Handlers must scope every query to token.org_uuid, never to a path or body +// value. +pub struct ScimToken { + pub org_uuid: OrganizationId, +} + +impl ScimToken { + // Splits "scim_v1.." into (org_uuid, secret). + fn parse_token(token: &str) -> Option<(&str, &str)> { + let rest = token.strip_prefix("scim_v1.")?; + let (org_uuid, secret) = rest.split_once('.')?; + if org_uuid.is_empty() || secret.is_empty() { + return None; + } + Some((org_uuid, secret)) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ScimToken { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let Outcome::Success(ip) = ClientIp::from_request(request).await else { + err_handler!("Error getting Client IP") + }; + + // Rate limit before any parsing or database work. + if ratelimit::check_limit_scim(&ip.ip).is_err() { + warn!(target: "auth", "SCIM rate limit exceeded. IP: {}", ip.ip); + return Outcome::Error((Status::TooManyRequests, "Too many requests")); + } + + if !CONFIG.scim_enabled() { + err_handler!("SCIM is disabled") + } + + let Some(auth_header) = request.headers().get_one("Authorization") else { + err_handler!("No access token provided") + }; + let Some(bearer) = auth_header.strip_prefix("Bearer ") else { + err_handler!("No access token provided") + }; + + let Some((token_org, secret)) = Self::parse_token(bearer) else { + err_handler!("Malformed SCIM token") + }; + + // The org embedded in the token must match the org in the request + // path, before any database work. Route shape is /v2//..., + // so the org id is path parameter index 1. + let Some(Ok(path_org)) = request.param::(1) else { + err_handler!("Missing org_id in path") + }; + if token_org != &*path_org { + err_handler!("SCIM token does not match the requested organization", format!("IP: {}", ip.ip)) + } + + let Outcome::Success(conn) = DbConn::from_request(request).await else { + err_handler!("Error getting DB") + }; + + let org_uuid: OrganizationId = token_org.to_owned().into(); + let Some(scim_key) = ScimApiKey::find_active_by_org(&org_uuid, &conn).await else { + // Burn an equivalent comparison so a missing key row does not + // return faster than a wrong secret. + let _ = crate::crypto::ct_eq(DUMMY_DIGEST, crate::crypto::sha256_hex(secret.as_bytes())); + err_handler!("No active SCIM api key for organization", format!("IP: {}. Organization: {org_uuid}", ip.ip)) + }; + if !scim_key.check_valid_secret(secret) { + err_handler!("Invalid SCIM token secret", format!("IP: {}. Organization: {org_uuid}", ip.ip)) + } + + Outcome::Success(ScimToken { + org_uuid, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_token_valid() { + let parsed = ScimToken::parse_token("scim_v1.11111111-2222-3333-4444-555555555555.some-secret-value"); + assert_eq!(parsed, Some(("11111111-2222-3333-4444-555555555555", "some-secret-value"))); + } + + #[test] + fn parse_token_secret_may_contain_dots() { + // Only the first dot after the org uuid separates; the secret keeps the rest. + let parsed = ScimToken::parse_token("scim_v1.org-uuid.part1.part2"); + assert_eq!(parsed, Some(("org-uuid", "part1.part2"))); + } + + #[test] + fn parse_token_rejects_bad_shapes() { + assert_eq!(ScimToken::parse_token(""), None); + assert_eq!(ScimToken::parse_token("scim_v1."), None); + assert_eq!(ScimToken::parse_token("scim_v1.orgonly"), None); + assert_eq!(ScimToken::parse_token("scim_v1..secret"), None); + assert_eq!(ScimToken::parse_token("scim_v1.org."), None); + assert_eq!(ScimToken::parse_token("scim_v2.org.secret"), None); + assert_eq!(ScimToken::parse_token("Bearer scim_v1.org.secret"), None); + } +} diff --git a/src/api/scim/manage.rs b/src/api/scim/manage.rs new file mode 100644 index 00000000..88d20cdd --- /dev/null +++ b/src/api/scim/manage.rs @@ -0,0 +1,95 @@ +// +// Management endpoints for the per-organization SCIM api key. These live +// under /api (not /scim) and require an interactive admin session plus +// password or OTP re-authentication, mirroring the existing organization +// api-key rotation endpoints. +// +// The plaintext token is returned exactly once, from the generate/rotate +// call. Only its sha256 digest is stored. +// +use rocket::{Route, serde::json::Json}; + +use crate::{ + CONFIG, + api::{EmptyResult, JsonResult, PasswordOrOtpData}, + auth::AdminHeaders, + crypto, + db::{ + DbConn, + models::{OrganizationId, ScimApiKey}, + }, + util::format_date, +}; + +pub fn routes() -> Vec { + routes![generate_scim_key, delete_scim_key, scim_status] +} + +fn check_scim_enabled() -> EmptyResult { + if !CONFIG.scim_enabled() { + err!("SCIM support is disabled") + } + Ok(()) +} + +#[post("/organizations//scim/api-key", data = "")] +async fn generate_scim_key( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + check_scim_enabled()?; + data.into_inner().validate(&headers.user, true, &conn).await?; + + // 256-bit secret; the stored digest can only be brute-forced, not inverted. + let secret = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD); + let key_hash = crypto::sha256_hex(secret.as_bytes()); + + // Rotation is replacement: any previous key stops working immediately. + ScimApiKey::delete_all_by_organization(&org_id, &conn).await?; + let scim_key = ScimApiKey::new(org_id.clone(), key_hash); + scim_key.save(&conn).await?; + + Ok(Json(json!({ + "object": "scim-api-key", + "token": format!("scim_v1.{org_id}.{secret}"), + "scimBaseUrl": format!("{}/scim/v2/{org_id}", CONFIG.domain()), + "revisionDate": format_date(&scim_key.revision_date), + }))) +} + +#[delete("/organizations//scim/api-key", data = "")] +async fn delete_scim_key( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + data.into_inner().validate(&headers.user, true, &conn).await?; + + ScimApiKey::delete_all_by_organization(&org_id, &conn).await +} + +#[get("/organizations//scim/status")] +async fn scim_status(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + let scim_key = ScimApiKey::find_by_org(&org_id, &conn).await; + Ok(Json(json!({ + "object": "scim-status", + "scimEnabled": CONFIG.scim_enabled(), + "keyConfigured": scim_key.is_some(), + "keyEnabled": scim_key.as_ref().is_some_and(|k| k.enabled), + "createdAt": scim_key.as_ref().map(|k| format_date(&k.created_at)), + "revisionDate": scim_key.as_ref().map(|k| format_date(&k.revision_date)), + }))) +} diff --git a/src/api/scim/mod.rs b/src/api/scim/mod.rs new file mode 100644 index 00000000..763d895c --- /dev/null +++ b/src/api/scim/mod.rs @@ -0,0 +1,90 @@ +// +// SCIM v2 provisioning endpoints (RFC 7643 / RFC 7644), Entra ID first. +// +// Mounted at /scim (see main.rs). Authentication is the per-organization +// static bearer token checked by guard::ScimToken. Management of that token +// is an admin-session concern and lives under /api via manage::routes(). +// +pub mod guard; + +mod discovery; +mod error; +mod manage; + +use std::io::Cursor; + +use rocket::{ + Catcher, Route, + http::Status, + request::Request, + response::{self, Responder, Response}, +}; +use serde_json::Value; + +pub use error::ScimError; +pub use manage::routes as manage_routes; + +pub fn routes() -> Vec { + discovery::routes() +} + +// A JSON body with Content-Type application/scim+json, RFC 7644 section 3.1. +pub struct ScimResponse { + status: Status, + body: Value, +} + +impl ScimResponse { + pub fn ok(body: Value) -> Self { + Self { + status: Status::Ok, + body, + } + } +} + +impl Responder<'_, 'static> for ScimResponse { + fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { + let body = self.body.to_string(); + Response::build() + .status(self.status) + .header(error::scim_content_type()) + .sized_body(Some(body.len()), Cursor::new(body)) + .ok() + } +} + +// Catchers keep every error under /scim inside the SCIM envelope, including +// guard rejections (which carry only a status). The 401 body is a constant: +// all auth failure causes look identical to the caller. +pub fn catchers() -> Vec { + catchers![scim_bad_request, scim_unauthorized, scim_not_found, scim_too_many_requests, scim_internal] +} + +#[catch(400)] +fn scim_bad_request() -> ScimError { + ScimError::bad_request("invalidValue", "Bad request") +} + +#[catch(401)] +fn scim_unauthorized() -> ScimError { + ScimError::unauthorized() +} + +#[catch(404)] +fn scim_not_found() -> ScimError { + ScimError::not_found() +} + +#[catch(429)] +fn scim_too_many_requests() -> ScimError { + ScimError::too_many_requests() +} + +#[catch(500)] +fn scim_internal() -> ScimError { + ScimError::internal() +} + +#[cfg(all(test, sqlite))] +mod tests; diff --git a/src/api/scim/tests/mod.rs b/src/api/scim/tests/mod.rs new file mode 100644 index 00000000..767805ca --- /dev/null +++ b/src/api/scim/tests/mod.rs @@ -0,0 +1,192 @@ +// +// Integration tests for the SCIM endpoints, driven through a Rocket local +// client against a temporary sqlite database. +// +// Environment: a #[ctor] constructor calls test_support::init_hermetic_env() +// BEFORE main and before any test thread starts. CONFIG is a process-global +// LazyLock that reads the environment on first deref, so this is the only +// point where its inputs can be controlled race-free. The main crate forbids +// unsafe code, which is why the env mutation lives in the test-support crate. +// +// Tests that build a rocket + database serialize on TEST_LOCK: the sqlite +// file and the CONFIG global are shared process state. +// +use std::sync::LazyLock; + +use rocket::{ + http::{Header, Status}, + local::asynchronous::{Client, LocalResponse}, +}; + +use crate::{ + api::scim, + crypto, + db::{ + DbConn, DbPool, + models::{Organization, OrganizationId, ScimApiKey}, + }, +}; + +// Linking test-support activates its #[ctor] constructor, which points CONFIG +// at a hermetic temp environment before main. See test-support/src/lib.rs. +use test_support as _; + +static TEST_LOCK: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); + +const SCIM_CONTENT_TYPE: &str = "application/scim+json"; + +// One pool for the whole test process: it is never dropped, so no test can +// hit "database is locked" from a previous test's lazily-dropped connections, +// and the embedded migrations run exactly once. +fn test_pool() -> &'static DbPool { + static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + // main() installs the rustls provider at startup; the test binary + // never runs main, and the CONFIG load path builds an http client + // that needs it. + rustls::crypto::ring::default_provider().install_default().expect("install rustls crypto provider"); + DbPool::from_config().expect("test db pool (migrations embedded)") + }) +} + +async fn scim_client() -> (Client, DbPool) { + let pool = test_pool().clone(); + let rocket = rocket::custom(rocket::Config::default()) + .mount("/scim", scim::routes()) + .register("/scim", scim::catchers()) + .manage(pool.clone()); + let client = Client::untracked(rocket).await.expect("local rocket client"); + (client, pool) +} + +async fn seed_org(conn: &DbConn, name: &str) -> OrganizationId { + let org = Organization::new(String::from(name), "admin@example.com", None, None); + org.save(conn).await.expect("saving test org"); + org.uuid +} + +// Mirrors manage::generate_scim_key and returns the full bearer token. +async fn seed_scim_key(conn: &DbConn, org_uuid: &OrganizationId) -> String { + let secret = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD); + let key_hash = crypto::sha256_hex(secret.as_bytes()); + ScimApiKey::new(org_uuid.clone(), key_hash).save(conn).await.expect("saving scim key"); + format!("scim_v1.{org_uuid}.{secret}") +} + +fn bearer(token: &str) -> Header<'static> { + Header::new("Authorization", format!("Bearer {token}")) +} + +async fn body_of(response: LocalResponse<'_>) -> String { + response.into_string().await.expect("response body") +} + +#[rocket::async_test] +async fn valid_token_reaches_discovery() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-authz-ok").await; + let token = seed_scim_key(&conn, &org).await; + + let response = client.get(format!("/scim/v2/{org}/ServiceProviderConfig")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let content_type = response.headers().get_one("Content-Type").expect("content type"); + assert!(content_type.starts_with(SCIM_CONTENT_TYPE), "unexpected content type {content_type}"); + let body = body_of(response).await; + assert!(body.contains("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")); + + let response = client.get(format!("/scim/v2/{org}/ResourceTypes")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let body = body_of(response).await; + assert!(body.contains("urn:ietf:params:scim:api:messages:2.0:ListResponse")); + assert!(body.contains("\"endpoint\":\"/Users\"")); + + let response = client.get(format!("/scim/v2/{org}/Schemas")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn auth_failures_are_uniform_401s() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + + let org_a = seed_org(&conn, "scim-authz-a").await; + let token_a = seed_scim_key(&conn, &org_a).await; + let org_b = seed_org(&conn, "scim-authz-b").await; + let _token_b = seed_scim_key(&conn, &org_b).await; + // An org that exists but has no SCIM key configured. + let org_c = seed_org(&conn, "scim-authz-c").await; + + let url_a = format!("/scim/v2/{org_a}/ServiceProviderConfig"); + + let mut failures: Vec<(&str, LocalResponse<'_>)> = Vec::new(); + failures.push(("no auth header", client.get(&url_a).dispatch().await)); + failures + .push(("not a bearer", client.get(&url_a).header(Header::new("Authorization", "Basic abc")).dispatch().await)); + failures.push(("malformed token", client.get(&url_a).header(bearer("garbage")).dispatch().await)); + failures.push(( + "wrong version prefix", + client.get(&url_a).header(bearer(&format!("scim_v0.{org_a}.x"))).dispatch().await, + )); + failures.push(( + "wrong secret", + client.get(&url_a).header(bearer(&format!("scim_v1.{org_a}.bm90LXRoZS1zZWNyZXQ"))).dispatch().await, + )); + failures.push(( + "token org != path org", + client.get(format!("/scim/v2/{org_b}/ServiceProviderConfig")).header(bearer(&token_a)).dispatch().await, + )); + failures.push(( + "org without key", + client + .get(format!("/scim/v2/{org_c}/ServiceProviderConfig")) + .header(bearer(&format!("scim_v1.{org_c}.c2VjcmV0"))) + .dispatch() + .await, + )); + + let mut bodies = Vec::new(); + for (case, response) in failures { + assert_eq!(response.status(), Status::Unauthorized, "expected 401 for case: {case}"); + bodies.push((case, body_of(response).await)); + } + + // Every failure cause must produce a byte-identical body: no signal about + // which check rejected the request. + let (_, first) = &bodies[0]; + for (case, body) in &bodies { + assert_eq!(body, first, "401 body differs for case: {case}"); + } + assert!(first.contains("urn:ietf:params:scim:api:messages:2.0:Error")); + assert!(first.contains("\"status\":\"401\"")); +} + +#[rocket::async_test] +async fn unknown_scim_route_is_scim_enveloped_404() { + let _guard = TEST_LOCK.lock().await; + let (client, _pool) = scim_client().await; + + let response = client.get("/scim/v2/some-org/Nope").dispatch().await; + assert_eq!(response.status(), Status::NotFound); + let body = body_of(response).await; + assert!(body.contains("urn:ietf:params:scim:api:messages:2.0:Error")); + assert!(body.contains("\"status\":\"404\"")); +} + +#[test] +fn rate_limiter_returns_429_when_drained() { + // Uses a synthetic IP so draining this bucket cannot affect the shared + // bucket the HTTP tests consume from (the limiter is keyed by IP). + let ip: std::net::IpAddr = "10.99.99.99".parse().expect("test ip"); + let burst = crate::CONFIG.scim_ratelimit_max_burst(); + let mut limited = false; + for _ in 0..=burst { + if crate::ratelimit::check_limit_scim(&ip).is_err() { + limited = true; + break; + } + } + assert!(limited, "limiter never tripped after {burst} + 1 requests"); +} diff --git a/src/config.rs b/src/config.rs index 49281b6c..de0ebfa3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -779,6 +779,13 @@ make_config! { /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; + /// Enable SCIM v2 provisioning (BETA!) |> Master switch for the /scim/v2 endpoints. An organization also needs a generated SCIM API key before its endpoints accept requests. + scim_enabled: bool, false, def, false; + /// Seconds between SCIM requests |> Number of seconds, on average, between SCIM requests from the same IP address before rate limiting kicks in + scim_ratelimit_seconds: u64, false, def, 1; + /// Max burst size for SCIM requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `scim_ratelimit_seconds`. Entra ID sends bursts during sync cycles. + scim_ratelimit_max_burst: u32, false, def, 60; + /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..38fde691 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,7 @@ mod folder; mod group; mod org_policy; mod organization; +mod scim_api_key; mod send; mod sso_auth; mod two_factor; @@ -34,6 +35,7 @@ pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; +pub use self::scim_api_key::ScimApiKey; pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72b1df0b..d8dc8753 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -383,6 +383,7 @@ impl Organization { OrgPolicy::delete_all_by_organization(&self.uuid, conn).await?; Group::delete_all_by_organization(&self.uuid, conn).await?; OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?; + super::ScimApiKey::delete_all_by_organization(&self.uuid, conn).await?; conn.run(move |conn| { diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid))) diff --git a/src/db/models/scim_api_key.rs b/src/db/models/scim_api_key.rs new file mode 100644 index 00000000..0bab3ae2 --- /dev/null +++ b/src/db/models/scim_api_key.rs @@ -0,0 +1,105 @@ +use chrono::{NaiveDateTime, Utc}; +use derive_more::Display; +use diesel::prelude::*; + +use crate::{ + api::EmptyResult, + crypto, + db::{DbConn, models::OrganizationId, schema::scim_api_key}, + error::MapResult, +}; + +#[derive(Identifiable, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = scim_api_key)] +#[diesel(primary_key(uuid))] +pub struct ScimApiKey { + pub uuid: ScimApiKeyId, + pub org_uuid: OrganizationId, + // sha256 hex digest of the token secret. The plaintext secret is shown once + // at generation time and never stored or logged. + pub key_hash: String, + pub enabled: bool, + pub created_at: NaiveDateTime, + pub revision_date: NaiveDateTime, +} + +#[derive(Clone, Debug, DieselNewType, Display, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScimApiKeyId(String); + +impl ScimApiKey { + pub fn new(org_uuid: OrganizationId, key_hash: String) -> Self { + let now = Utc::now().naive_utc(); + Self { + uuid: ScimApiKeyId(crate::util::get_uuid()), + org_uuid, + key_hash, + enabled: true, + created_at: now, + revision_date: now, + } + } + + // Constant-time comparison against a plaintext secret's digest. A dummy + // digest is compared when no key row exists so the caller can keep the + // verification cost independent of row presence. + pub fn check_valid_secret(&self, secret: &str) -> bool { + crypto::ct_eq(&self.key_hash, crypto::sha256_hex(secret.as_bytes())) + } + + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + // Rotation replaces the row wholesale (delete + insert), so a plain + // insert with an update fallback covers every backend identically. + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(scim_api_key::table) + .values(self) + .execute(conn) + { + Ok(_) => Ok(()), + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(scim_api_key::table) + .filter(scim_api_key::uuid.eq(&self.uuid)) + .set(self) + .execute(conn) + .map_res("Error saving SCIM api key") + } + Err(e) => Err(e.into()), + }.map_res("Error saving SCIM api key") + } + postgresql { + diesel::insert_into(scim_api_key::table) + .values(self) + .on_conflict(scim_api_key::uuid) + .do_update() + .set(self) + .execute(conn) + .map_res("Error saving SCIM api key") + } + } + } + + pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Option { + conn.run(move |conn| scim_api_key::table.filter(scim_api_key::org_uuid.eq(org_uuid)).first::(conn).ok()) + .await + } + + pub async fn find_active_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Option { + conn.run(move |conn| { + scim_api_key::table + .filter(scim_api_key::org_uuid.eq(org_uuid)) + .filter(scim_api_key::enabled.eq(true)) + .first::(conn) + .ok() + }) + .await + } + + pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete(scim_api_key::table.filter(scim_api_key::org_uuid.eq(org_uuid))) + .execute(conn) + .map_res("Error removing SCIM api key from organization") + }) + .await + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..23b92060 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -255,6 +255,17 @@ table! { } } +table! { + scim_api_key (uuid) { + uuid -> Text, + org_uuid -> Text, + key_hash -> Text, + enabled -> Bool, + created_at -> Timestamp, + revision_date -> Timestamp, + } +} + table! { sso_auth (state) { state -> Text, @@ -373,6 +384,7 @@ joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(users_organizations -> ciphers (org_uuid)); joinable!(organization_api_key -> organizations (org_uuid)); +joinable!(scim_api_key -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); joinable!(groups -> organizations (organizations_uuid)); joinable!(groups_users -> users_organizations (users_organizations_uuid)); @@ -402,6 +414,7 @@ allow_tables_to_appear_in_same_query!( users_collections, users_organizations, organization_api_key, + scim_api_key, emergency_access, groups, groups_users, diff --git a/src/main.rs b/src/main.rs index 15467ea4..20cd7baf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -577,7 +577,8 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> config.limits = Limits::new() .limit("json", 20.megabytes()) // 20MB should be enough for very large imports, something like 5000+ vault entries .limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send - .limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments + .limit("file", 525.megabytes()) // This needs to match the maximum allowed file size for attachments + .limit("scim", 512.kibibytes()); // SCIM bodies are small; a tight cap limits abuse of the machine-auth endpoint // If adding more paths here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log @@ -589,9 +590,11 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .mount([basepath, "/identity"].concat(), api::identity_routes()) .mount([basepath, "/icons"].concat(), api::icons_routes()) .mount([basepath, "/notifications"].concat(), api::notifications_routes()) + .mount([basepath, "/scim"].concat(), api::scim_routes()) .register([basepath, "/"].concat(), api::web_catchers()) .register([basepath, "/api"].concat(), api::core_catchers()) .register([basepath, "/admin"].concat(), api::admin_catchers()) + .register([basepath, "/scim"].concat(), api::scim_catchers()) .manage(pool) .manage(Arc::clone(&WS_USERS)) .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 2b422924..ee4627c4 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -18,6 +18,12 @@ static LIMITER_ADMIN: LazyLock = LazyLock::new(|| { RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) }); +static LIMITER_SCIM: LazyLock = LazyLock::new(|| { + let seconds = Duration::from_secs(CONFIG.scim_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.scim_ratelimit_max_burst()).expect("Non-zero scim ratelimit burst"); + RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero scim ratelimit seconds").allow_burst(burst)) +}); + pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { match LIMITER_LOGIN.check_key(ip) { Ok(()) => Ok(()), @@ -35,3 +41,12 @@ pub fn check_limit_admin(ip: &IpAddr) -> Result<(), Error> { } } } + +pub fn check_limit_scim(ip: &IpAddr) -> Result<(), Error> { + match LIMITER_SCIM.check_key(ip) { + Ok(()) => Ok(()), + Err(_e) => { + err_code!("Too many SCIM requests", 429); + } + } +} diff --git a/test-support/Cargo.toml b/test-support/Cargo.toml new file mode 100644 index 00000000..84066ca7 --- /dev/null +++ b/test-support/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "test-support" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +ctor = "1.0.9" diff --git a/test-support/src/lib.rs b/test-support/src/lib.rs new file mode 100644 index 00000000..43eacb97 --- /dev/null +++ b/test-support/src/lib.rs @@ -0,0 +1,67 @@ +//! Test-only environment bootstrap. +//! +//! The main crate forbids `unsafe`, and `std::env::set_var` is `unsafe` on +//! edition 2024, so the mutation lives here. The `#[ctor]` constructor runs +//! before `main` and before any test thread exists, which is the only point +//! where the process environment can be changed race-free and the only point +//! early enough to beat the first deref of Vaultwarden's process-global +//! `CONFIG` (a `LazyLock` that reads the environment). +//! +//! Linkage is intentional: this crate only takes effect in test binaries that +//! reference it (`use test_support as _;`). Feature combos that do not compile +//! those test modules never link it and keep upstream behaviour. + +/// Points every config-relevant environment variable at a per-process +/// temporary directory and enables the flags the SCIM test suite needs. +/// +/// `DATABASE_URL` is deliberately left unset: Vaultwarden derives it as +/// `sqlite://{DATA_FOLDER}/db.sqlite3`, which lands inside the hermetic +/// directory, and hardcoding a sqlite URL here would poison config validation +/// for mysql/postgresql-only feature combos. +pub fn init_hermetic_env() { + let base = std::env::temp_dir().join(format!("vaultwarden-test-{}", std::process::id())); + let data = base.join("data"); + std::fs::create_dir_all(&data).expect("creating hermetic test data dir"); + + // An empty env file: a configured-but-missing ENV_FILE makes Vaultwarden + // exit at config load, and an unset one falls back to the repo's dev .env. + let env_file = base.join("test.env"); + std::fs::write(&env_file, "").expect("creating hermetic test env file"); + + let vars: &[(&str, String)] = &[ + ("ENV_FILE", env_file.to_string_lossy().into_owned()), + ("DATA_FOLDER", data.to_string_lossy().into_owned()), + ("DOMAIN", String::from("http://localhost:8000")), + ("WEB_VAULT_ENABLED", String::from("false")), + ("SIGNUPS_ALLOWED", String::from("true")), + ("INVITATIONS_ALLOWED", String::from("true")), + ("ORG_GROUPS_ENABLED", String::from("true")), + ("SCIM_ENABLED", String::from("true")), + ("SCIM_RATELIMIT_SECONDS", String::from("1")), + // High burst so the shared per-IP limiter never trips ordinary tests; + // the 429 path is exercised against a distinct synthetic IP. + ("SCIM_RATELIMIT_MAX_BURST", String::from("10000")), + ]; + + for (key, value) in vars { + // SAFETY: runs pre-main via #[ctor]; the process is single-threaded, + // so mutating the environment cannot race with any reader. + unsafe { + std::env::set_var(key, value); + } + } + + // Make sure a developer shell exporting SMTP settings cannot leak mail + // sending into tests. + for key in ["SMTP_HOST", "SMTP_FROM", "USE_SENDMAIL"] { + // SAFETY: as above. + unsafe { + std::env::remove_var(key); + } + } +} + +#[ctor::ctor(unsafe)] +fn hermetic_env() { + init_hermetic_env(); +}