Browse Source
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
25 changed files with 1011 additions and 2 deletions
@ -0,0 +1 @@ |
|||
DROP TABLE scim_api_key; |
|||
@ -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 |
|||
); |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE scim_api_key; |
|||
@ -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 |
|||
); |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE scim_api_key; |
|||
@ -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) |
|||
); |
|||
@ -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}") }, |
|||
}), |
|||
])) |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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)), |
|||
}))) |
|||
} |
|||
@ -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; |
|||
@ -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"); |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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" |
|||
@ -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…
Reference in new issue