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