Browse Source

Add SCIM v2 scaffolding: per-org api key, auth guard, discovery endpoints

Groundwork for SCIM v2 provisioning (RFC 7643/7644), targeting Entra ID:

- New scim_api_key table (sqlite/mysql/postgresql migrations) storing a
  sha256 digest of a per-organization static bearer secret. An active row
  doubles as per-org SCIM enablement; org deletion cleans it up.
- ScimToken request guard modeled on PublicToken: pre-auth rate limiting,
  scim_enabled gate, token format scim_v1.<org>.<secret>, token-org vs
  path-org check before any DB work, constant-time digest compare with a
  dummy compare on miss. All failure causes log server-side and surface as
  one uniform SCIM-enveloped 401.
- SCIM error envelope + catchers so every response under /scim (400/401/
  404/429/500) is application/scim+json.
- Discovery endpoints (ServiceProviderConfig, ResourceTypes, Schemas).
- Admin-session management endpoints under /api: generate/rotate (plaintext
  shown once), delete, and status; guarded by AdminHeaders plus password or
  OTP re-auth, mirroring rotate_api_key.
- Config: scim_enabled (default off) and SCIM rate limiter settings.
- Test infrastructure: hermetic pre-main env bootstrap (test-support crate,
  keeps CONFIG away from the developer's .env and data), Rocket local-client
  harness on temp sqlite, authz matrix asserting byte-identical 401 bodies,
  rate limiter drain test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/7443/head
David Croft 4 days ago
parent
commit
b1d0261800
  1. 30
      Cargo.lock
  2. 5
      Cargo.toml
  3. 1
      migrations/mysql/2026-07-19-000000_add_scim_api_key/down.sql
  4. 8
      migrations/mysql/2026-07-19-000000_add_scim_api_key/up.sql
  5. 1
      migrations/postgresql/2026-07-19-000000_add_scim_api_key/down.sql
  6. 8
      migrations/postgresql/2026-07-19-000000_add_scim_api_key/up.sql
  7. 1
      migrations/sqlite/2026-07-19-000000_add_scim_api_key/down.sql
  8. 9
      migrations/sqlite/2026-07-19-000000_add_scim_api_key/up.sql
  9. 1
      src/api/core/mod.rs
  10. 3
      src/api/mod.rs
  11. 109
      src/api/scim/discovery.rs
  12. 93
      src/api/scim/error.rs
  13. 142
      src/api/scim/guard.rs
  14. 95
      src/api/scim/manage.rs
  15. 90
      src/api/scim/mod.rs
  16. 192
      src/api/scim/tests/mod.rs
  17. 7
      src/config.rs
  18. 2
      src/db/models/mod.rs
  19. 1
      src/db/models/organization.rs
  20. 105
      src/db/models/scim_api_key.rs
  21. 13
      src/db/schema.rs
  22. 5
      src/main.rs
  23. 15
      src/ratelimit.rs
  24. 10
      test-support/Cargo.toml
  25. 67
      test-support/src/lib.rs

30
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",

5
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" }

1
migrations/mysql/2026-07-19-000000_add_scim_api_key/down.sql

@ -0,0 +1 @@
DROP TABLE scim_api_key;

8
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
);

1
migrations/postgresql/2026-07-19-000000_add_scim_api_key/down.sql

@ -0,0 +1 @@
DROP TABLE scim_api_key;

8
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
);

1
migrations/sqlite/2026-07-19-000000_add_scim_api_key/down.sql

@ -0,0 +1 @@
DROP TABLE scim_api_key;

9
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)
);

1
src/api/core/mod.rs

@ -47,6 +47,7 @@ pub fn routes() -> Vec<Route> {
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);

3
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,

109
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<Route> {
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}") },
}),
]))
}

93
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()
}
}

142
src/api/scim/guard.rs

@ -0,0 +1,142 @@
//
// Request guard for the /scim/v2/<org_id> endpoints.
//
// The credential is a per-organization static bearer token of the form
// scim_v1.<org_uuid>.<secret>
// 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.<org_uuid>.<secret>" 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<Self, Self::Error> {
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/<org_id>/...,
// so the org id is path parameter index 1.
let Some(Ok(path_org)) = request.param::<OrganizationId>(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);
}
}

95
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<Route> {
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/<org_id>/scim/api-key", data = "<data>")]
async fn generate_scim_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
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/<org_id>/scim/api-key", data = "<data>")]
async fn delete_scim_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
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/<org_id>/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)),
})))
}

90
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<Route> {
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<Catcher> {
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;

192
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<tokio::sync::Mutex<()>> = 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<DbPool> = 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");
}

7
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;

2
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};

1
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)))

105
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<Self> {
conn.run(move |conn| scim_api_key::table.filter(scim_api_key::org_uuid.eq(org_uuid)).first::<Self>(conn).ok())
.await
}
pub async fn find_active_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
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::<Self>(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
}
}

13
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,

5
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))

15
src/ratelimit.rs

@ -18,6 +18,12 @@ static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
});
static LIMITER_SCIM: LazyLock<Limiter> = 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);
}
}
}

10
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"

67
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();
}
Loading…
Cancel
Save