Browse Source

Add SCIM Users endpoints: provision, deprovision, restore

Phase 1 of the SCIM v2 implementation - the deprovisioning core:

- GET /scim/v2/<org>/Users with eq filters on userName/externalId (filter
  values are matched against the lowercased stored email, avoiding the
  Entra create-409-quarantine loop) plus startIndex/count pagination.
- POST creates or links the user and invites: shell account for unknown
  emails, membership at Invited (Accepted when mail is off and the account
  already has credentials), invite mail with rollback on failure. Honors
  active:false on create by storing the membership pre-revoked.
- PATCH handles Entra's active toggle in all observed shapes: op casing,
  string booleans, and the path-less value-object form.
- PATCH active:false and DELETE both map to revoke, never delete: the row
  and akey survive so restore is lossless. Documented RFC deviation.
- Last-confirmed-owner protection returns a SCIM 409 mutability error.
- Restore re-runs the org policy check like restore_member_impl.
- Unknown member ids and other orgs' member ids return identical 404s.
- Audit events (invited/revoked/restored) recorded with a synthetic SCIM
  actor, following the admin panel's ACTING_ADMIN_USER precedent.
- ScimJson data guard accepts application/scim+json bodies under the scim
  size limit; 413 catcher added.

Tests: 24 new (unit: filter grammar, PatchOp quirks, status-offset table
with the -126/-128 revocation encoding, regression canary on from_i32;
integration: full lifecycle, duplicate 409s, last-owner 409, enumeration
shape, born-disabled create).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/7443/head
David Croft 4 days ago
parent
commit
a4d1d870b1
  1. 16
      src/api/scim/error.rs
  2. 93
      src/api/scim/filter.rs
  3. 4
      src/api/scim/guard.rs
  4. 93
      src/api/scim/mod.rs
  5. 160
      src/api/scim/models.rs
  6. 159
      src/api/scim/patch.rs
  7. 368
      src/api/scim/tests/mod.rs
  8. 373
      src/api/scim/users.rs

16
src/api/scim/error.rs

@ -40,6 +40,22 @@ impl ScimError {
}
}
pub fn conflict(scim_type: &'static str, detail: &str) -> Self {
Self {
status: Status::Conflict,
scim_type: Some(scim_type),
detail: String::from(detail),
}
}
pub fn payload_too_large() -> Self {
Self {
status: Status::PayloadTooLarge,
scim_type: None,
detail: String::from("Payload too large"),
}
}
pub fn not_found() -> Self {
Self {
status: Status::NotFound,

93
src/api/scim/filter.rs

@ -0,0 +1,93 @@
//
// Minimal SCIM filter parser, RFC 7644 section 3.4.2.2.
//
// Only the form Entra uses for existence checks is supported:
// attribute eq "value"
// Anything else (and/or, co, sw, grouping, valuePath) is rejected with
// scimType invalidFilter. That is the correct signal for a partially
// supported filter grammar; 501 would claim filtering is entirely absent.
//
use crate::api::scim::error::ScimError;
#[derive(Debug, PartialEq, Eq)]
pub struct EqFilter {
// Lowercased: SCIM attribute names are case-insensitive (RFC 7643 s2.1).
pub attribute: String,
pub value: String,
}
pub fn parse_eq_filter(raw: &str) -> Result<EqFilter, ScimError> {
let unsupported =
|| ScimError::bad_request("invalidFilter", "Only filters of the form `attr eq \"value\"` are supported");
let trimmed = raw.trim();
// Split "attr eq "value"" on whitespace, keeping the quoted tail intact.
let (attribute, rest) = trimmed.split_once(char::is_whitespace).ok_or_else(unsupported)?;
let rest = rest.trim_start();
let (operator, rest) = rest.split_once(char::is_whitespace).ok_or_else(unsupported)?;
if !operator.eq_ignore_ascii_case("eq") {
return Err(unsupported());
}
let quoted = rest.trim();
let value = quoted.strip_prefix('"').and_then(|v| v.strip_suffix('"')).ok_or_else(unsupported)?;
if value.contains('"') || attribute.is_empty() || value.is_empty() {
return Err(unsupported());
}
Ok(EqFilter {
attribute: attribute.to_lowercase(),
value: value.to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> Result<EqFilter, ScimError> {
parse_eq_filter(raw)
}
#[test]
fn parses_username_eq() {
let f = parse("userName eq \"john@example.com\"").expect("valid filter");
assert_eq!(f.attribute, "username");
assert_eq!(f.value, "john@example.com");
}
#[test]
fn parses_externalid_eq_case_insensitive_attr_and_op() {
let f = parse("ExternalId EQ \"abc-123\"").expect("valid filter");
assert_eq!(f.attribute, "externalid");
assert_eq!(f.value, "abc-123");
}
#[test]
fn value_may_contain_spaces() {
let f = parse("displayName eq \"Jane van Der Berg\"").expect("valid filter");
assert_eq!(f.value, "Jane van Der Berg");
}
#[test]
fn rejects_unsupported_grammar() {
for raw in [
"",
"userName",
"userName eq",
"userName eq unquoted",
"userName co \"x\"",
"userName sw \"x\"",
"userName eq \"a\" and active eq true",
"emails[type eq \"work\"].value eq \"x\"",
"userName eq \"broken",
"userName eq \"\"",
] {
let result = parse(raw);
match result {
Err(e) => assert_eq!(e.scim_type, Some("invalidFilter"), "wrong scimType for {raw:?}"),
Ok(f) => panic!("filter {raw:?} unexpectedly parsed to {f:?}"),
}
}
}
}

4
src/api/scim/guard.rs

@ -34,9 +34,10 @@ use crate::{
const DUMMY_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000";
// Handlers must scope every query to token.org_uuid, never to a path or body
// value.
// value. The ip is carried for audit logging.
pub struct ScimToken {
pub org_uuid: OrganizationId,
pub ip: ClientIp,
}
impl ScimToken {
@ -108,6 +109,7 @@ impl<'r> FromRequest<'r> for ScimToken {
Outcome::Success(ScimToken {
org_uuid,
ip,
})
}
}

93
src/api/scim/mod.rs

@ -9,48 +9,109 @@ pub mod guard;
mod discovery;
mod error;
mod filter;
mod manage;
mod models;
mod patch;
mod users;
use std::io::Cursor;
use rocket::{
Catcher, Route,
data::{Data, FromData, Outcome as DataOutcome},
http::Status,
request::Request,
response::{self, Responder, Response},
};
use serde::de::DeserializeOwned;
use serde_json::Value;
pub use error::ScimError;
pub use manage::routes as manage_routes;
pub fn routes() -> Vec<Route> {
discovery::routes()
let mut routes = discovery::routes();
routes.append(&mut users::routes());
routes
}
// A JSON body limited by the "scim" size limit. Accepts both
// application/scim+json (what Entra sends) and application/json; rocket's
// own Json<T> would reject the former.
pub struct ScimJson<T>(pub T);
#[rocket::async_trait]
impl<'r, T: DeserializeOwned> FromData<'r> for ScimJson<T> {
type Error = ScimError;
async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> {
let limit = request.limits().get("scim").unwrap_or_else(|| rocket::data::ByteUnit::Kibibyte(512));
let bytes = match data.open(limit).into_bytes().await {
Ok(bytes) if bytes.is_complete() => bytes.into_inner(),
Ok(_) => return DataOutcome::Error((Status::PayloadTooLarge, ScimError::payload_too_large())),
Err(_) => {
return DataOutcome::Error((
Status::BadRequest,
ScimError::bad_request("invalidSyntax", "Unreadable body"),
));
}
};
match serde_json::from_slice::<T>(&bytes) {
Ok(value) => DataOutcome::Success(ScimJson(value)),
Err(e) => {
warn!(target: "scim", "Rejecting unparseable SCIM body: {e}");
DataOutcome::Error((Status::BadRequest, ScimError::bad_request("invalidSyntax", "Malformed SCIM body")))
}
}
}
}
// A JSON body with Content-Type application/scim+json, RFC 7644 section 3.1.
pub struct ScimResponse {
status: Status,
body: Value,
location: Option<String>,
body: Option<Value>,
}
impl ScimResponse {
pub fn ok(body: Value) -> Self {
Self {
status: Status::Ok,
body,
location: None,
body: Some(body),
}
}
pub fn created(location: String, body: Value) -> Self {
Self {
status: Status::Created,
location: Some(location),
body: Some(body),
}
}
pub fn no_content() -> Self {
Self {
status: Status::NoContent,
location: None,
body: None,
}
}
}
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()
let mut builder = Response::build();
builder.status(self.status);
if let Some(location) = self.location {
builder.raw_header("Location", location);
}
if let Some(body) = self.body {
let body = body.to_string();
builder.header(error::scim_content_type()).sized_body(Some(body.len()), Cursor::new(body));
}
builder.ok()
}
}
@ -58,7 +119,14 @@ impl Responder<'_, 'static> for ScimResponse {
// 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]
catchers![
scim_bad_request,
scim_unauthorized,
scim_not_found,
scim_payload_too_large,
scim_too_many_requests,
scim_internal
]
}
#[catch(400)]
@ -76,6 +144,11 @@ fn scim_not_found() -> ScimError {
ScimError::not_found()
}
#[catch(413)]
fn scim_payload_too_large() -> ScimError {
ScimError::payload_too_large()
}
#[catch(429)]
fn scim_too_many_requests() -> ScimError {
ScimError::too_many_requests()

160
src/api/scim/models.rs

@ -0,0 +1,160 @@
//
// Serde models for SCIM request bodies, RFC 7643.
//
// Deliberately NOT #[serde(deny_unknown_fields)]: RFC 7643 section 2.1
// requires service providers to ignore attributes they do not recognize, and
// Entra sends several (addresses, phoneNumbers, preferredLanguage, ...).
//
use serde::Deserialize;
use serde_json::Value;
// Entra sometimes sends booleans as strings ("True"/"False"), particularly in
// PATCH values. Accept both without losing strictness for other types.
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(try_from = "Value")]
pub struct ScimBool(pub bool);
impl TryFrom<Value> for ScimBool {
type Error = String;
fn try_from(value: Value) -> Result<Self, Self::Error> {
match value {
Value::Bool(b) => Ok(ScimBool(b)),
Value::String(s) => match s.to_lowercase().as_str() {
"true" => Ok(ScimBool(true)),
"false" => Ok(ScimBool(false)),
_ => Err(format!("not a boolean: {s:?}")),
},
other => Err(format!("not a boolean: {other}")),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScimName {
pub given_name: Option<String>,
pub family_name: Option<String>,
pub formatted: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScimEmail {
pub value: String,
#[serde(default)]
pub primary: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScimUserRequest {
pub user_name: Option<String>,
pub external_id: Option<String>,
pub display_name: Option<String>,
pub name: Option<ScimName>,
#[serde(default)]
pub emails: Vec<ScimEmail>,
pub active: Option<ScimBool>,
}
impl ScimUserRequest {
// Entra maps userName to the login identifier; some tenants map the
// routable address only into emails[]. Prefer userName when it looks like
// an email, else fall back to the primary (or first) email value.
pub fn email(&self) -> Option<&str> {
let user_name = self.user_name.as_deref().filter(|v| v.contains('@'));
let from_emails =
self.emails.iter().find(|e| e.primary).or_else(|| self.emails.first()).map(|e| e.value.as_str());
user_name.or(from_emails)
}
// Compose a display name the way the web vault shows members.
pub fn display_name(&self) -> Option<String> {
if let Some(display_name) = &self.display_name {
return Some(display_name.clone());
}
if let Some(name) = &self.name {
if let Some(formatted) = &name.formatted {
return Some(formatted.clone());
}
let composed = match (&name.given_name, &name.family_name) {
(Some(given), Some(family)) => Some(format!("{given} {family}")),
(Some(given), None) => Some(given.clone()),
(None, Some(family)) => Some(family.clone()),
(None, None) => None,
};
return composed;
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scim_bool_coerces_entra_strings() {
for (raw, expected) in [
(json!(true), true),
(json!(false), false),
(json!("True"), true),
(json!("true"), true),
(json!("FALSE"), false),
(json!("false"), false),
] {
let b: ScimBool = serde_json::from_value(raw).expect("coercible");
assert_eq!(b.0, expected);
}
for bad in [json!("yes"), json!(1), json!(null), json!({})] {
assert!(serde_json::from_value::<ScimBool>(bad).is_err());
}
}
#[test]
fn email_prefers_username_then_primary_email() {
let parsed: ScimUserRequest = serde_json::from_value(json!({
"userName": "user@example.com",
"emails": [{"value": "other@example.com", "primary": true}],
}))
.expect("valid");
assert_eq!(parsed.email(), Some("user@example.com"));
// Non-email userName (a bare UPN-less identifier) falls back to emails.
let parsed: ScimUserRequest = serde_json::from_value(json!({
"userName": "just-an-id",
"emails": [{"value": "second@example.com"}, {"value": "first@example.com", "primary": true}],
}))
.expect("valid");
assert_eq!(parsed.email(), Some("first@example.com"));
let parsed: ScimUserRequest = serde_json::from_value(json!({})).expect("valid");
assert_eq!(parsed.email(), None);
}
#[test]
fn display_name_composition() {
let parsed: ScimUserRequest = serde_json::from_value(json!({
"name": {"givenName": "Ada", "familyName": "Lovelace"},
}))
.expect("valid");
assert_eq!(parsed.display_name().as_deref(), Some("Ada Lovelace"));
let parsed: ScimUserRequest = serde_json::from_value(json!({
"displayName": "Display Wins",
"name": {"formatted": "Formatted Loses"},
}))
.expect("valid");
assert_eq!(parsed.display_name().as_deref(), Some("Display Wins"));
// Unknown attributes are ignored per RFC 7643 section 2.1.
let parsed: ScimUserRequest = serde_json::from_value(json!({
"userName": "a@b.com",
"preferredLanguage": "en-US",
"addresses": [],
}))
.expect("unknown attrs must not fail");
assert_eq!(parsed.email(), Some("a@b.com"));
}
}

159
src/api/scim/patch.rs

@ -0,0 +1,159 @@
//
// SCIM PatchOp parsing, RFC 7644 section 3.5.2, restricted to what the User
// endpoints support. Entra quirks handled here, all observed in real syncs:
// - "op" arrives with any casing ("Replace", "Add").
// - boolean values may arrive as strings ("True"/"False").
// - an operation may have no "path", carrying a value object instead
// ({"op":"replace","value":{"active":false}}).
//
use serde::Deserialize;
use serde_json::Value;
use crate::api::scim::{error::ScimError, models::ScimBool};
pub const PATCH_OP_URN: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
#[derive(Debug, Deserialize)]
pub struct PatchOp {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(rename = "Operations", default)]
pub operations: Vec<PatchOperation>,
}
#[derive(Debug, Deserialize)]
pub struct PatchOperation {
pub op: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub value: Option<Value>,
}
// What a User PATCH asked for, after normalization.
#[derive(Debug, Default, PartialEq)]
pub struct UserPatch {
pub active: Option<bool>,
}
pub fn parse_user_patch(patch: &PatchOp) -> Result<UserPatch, ScimError> {
if !patch.schemas.iter().any(|s| s == PATCH_OP_URN) {
return Err(ScimError::bad_request("invalidValue", "Missing PatchOp schema"));
}
if patch.operations.is_empty() {
return Err(ScimError::bad_request("invalidValue", "No operations provided"));
}
let mut result = UserPatch::default();
for operation in &patch.operations {
let op = operation.op.to_lowercase();
if op != "replace" && op != "add" {
return Err(ScimError::bad_request(
"invalidValue",
"Only add and replace operations are supported for Users",
));
}
match operation.path.as_deref() {
Some(op_path) if op_path.eq_ignore_ascii_case("active") => {
let value = operation.value.clone().unwrap_or(Value::Null);
result.active = Some(coerce_bool(value)?);
}
Some(_) => {
return Err(ScimError::bad_request("invalidPath", "Unsupported patch path"));
}
None => {
// Path-less form: the value is an object of attribute => value.
let Some(Value::Object(map)) = operation.value.as_ref() else {
return Err(ScimError::bad_request(
"invalidValue",
"Operation without path must carry an object value",
));
};
for (attribute, value) in map {
if attribute.eq_ignore_ascii_case("active") {
result.active = Some(coerce_bool(value.clone())?);
} else {
return Err(ScimError::bad_request("invalidPath", "Unsupported patch attribute"));
}
}
}
}
}
Ok(result)
}
fn coerce_bool(value: Value) -> Result<bool, ScimError> {
serde_json::from_value::<ScimBool>(value)
.map(|b| b.0)
.map_err(|_| ScimError::bad_request("invalidValue", "Expected a boolean value for active"))
}
#[cfg(test)]
mod tests {
use super::*;
fn patch(payload: Value) -> Result<UserPatch, ScimError> {
let parsed: PatchOp = serde_json::from_value(payload).expect("valid PatchOp json");
parse_user_patch(&parsed)
}
fn ok_active(payload: Value) -> Option<bool> {
patch(payload).expect("expected parse to succeed").active
}
#[test]
fn replace_active_with_path() {
let active = ok_active(json!({
"schemas": [PATCH_OP_URN],
"Operations": [{"op": "replace", "path": "active", "value": false}],
}));
assert_eq!(active, Some(false));
}
#[test]
fn entra_casing_and_string_bool() {
let active = ok_active(json!({
"schemas": [PATCH_OP_URN],
"Operations": [{"op": "Replace", "path": "active", "value": "False"}],
}));
assert_eq!(active, Some(false));
}
#[test]
fn pathless_value_object() {
let active = ok_active(json!({
"schemas": [PATCH_OP_URN],
"Operations": [{"op": "replace", "value": {"active": "True"}}],
}));
assert_eq!(active, Some(true));
}
#[test]
fn rejects_bad_patches() {
for (payload, expected_type) in [
(
json!({"schemas": [], "Operations": [{"op": "replace", "path": "active", "value": true}]}),
"invalidValue",
),
(json!({"schemas": [PATCH_OP_URN], "Operations": []}), "invalidValue"),
(json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "remove", "path": "active"}]}), "invalidValue"),
(
json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "path": "displayName", "value": "x"}]}),
"invalidPath",
),
(
json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "path": "active", "value": "maybe"}]}),
"invalidValue",
),
(json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "value": 3}]}), "invalidValue"),
] {
match patch(payload.clone()) {
Err(e) => assert_eq!(e.scim_type, Some(expected_type), "wrong scimType for {payload}"),
Ok(p) => panic!("patch {payload} unexpectedly parsed to {p:?}"),
}
}
}
}

368
src/api/scim/tests/mod.rs

@ -18,12 +18,14 @@ use rocket::{
local::asynchronous::{Client, LocalResponse},
};
use serde_json::Value;
use crate::{
api::scim,
crypto,
db::{
DbConn, DbPool,
models::{Organization, OrganizationId, ScimApiKey},
models::{Membership, MembershipId, MembershipType, Organization, OrganizationId, ScimApiKey, User},
},
};
@ -190,3 +192,367 @@ fn rate_limiter_returns_429_when_drained() {
}
assert!(limited, "limiter never tripped after {burst} + 1 requests");
}
// ---------------------------------------------------------------------------
// Users lifecycle (Phase 1: provision, deprovision, restore)
// ---------------------------------------------------------------------------
fn scim_body(header_token: &str, body: &Value) -> (Header<'static>, Header<'static>, String) {
(bearer(header_token), Header::new("Content-Type", "application/scim+json"), body.to_string())
}
async fn seed_user(conn: &DbConn, email: &str, with_password: bool) -> User {
let mut user = User::new(email, None);
if with_password {
user.password_hash = vec![1, 2, 3];
}
user.save(conn).await.expect("saving test user");
user
}
async fn seed_member(
conn: &DbConn,
org: &OrganizationId,
email: &str,
status: i32,
atype: MembershipType,
) -> MembershipId {
let user = seed_user(conn, email, true).await;
let mut member = Membership::new(user.uuid, org.clone(), None);
member.status = status;
member.atype = atype as i32;
member.save(conn).await.expect("saving test member");
member.uuid
}
async fn member_status(conn: &DbConn, member_id: &MembershipId, org: &OrganizationId) -> i32 {
Membership::find_by_uuid_and_org(member_id, org, conn).await.expect("membership row must exist").status
}
fn parse_json(body: &str) -> Value {
serde_json::from_str(body).expect("valid json body")
}
#[rocket::async_test]
async fn post_creates_invited_user() {
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-post-org").await;
let token = seed_scim_key(&conn, &org).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "Provision.Me@Example.com",
"externalId": "entra-obj-001",
"name": {"givenName": "Provision", "familyName": "Me"},
"emails": [{"value": "Provision.Me@Example.com", "primary": true}],
"active": true,
});
let (auth, content_type, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
let location = response.headers().get_one("Location").expect("Location header").to_string();
let parsed = parse_json(&body_of(response).await);
// Mail is disabled and the user is new (no password): Invited (0).
assert_eq!(parsed["active"], json!(true));
assert_eq!(parsed["userName"], json!("provision.me@example.com"), "email must be stored lowercased");
assert_eq!(parsed["externalId"], json!("entra-obj-001"));
let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into();
assert!(location.ends_with(&format!("/scim/v2/{org}/Users/{member_id}")));
assert_eq!(member_status(&conn, &member_id, &org).await, 0, "new shell user must land at Invited");
assert!(User::find_by_mail("provision.me@example.com", &conn).await.is_some());
}
#[rocket::async_test]
async fn post_existing_credentialed_user_becomes_accepted() {
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-post-accepted-org").await;
let token = seed_scim_key(&conn, &org).await;
seed_user(&conn, "has.password@example.com", true).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "has.password@example.com",
"externalId": "entra-obj-002",
});
let (auth, content_type, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
let parsed = parse_json(&body_of(response).await);
let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into();
// Mail disabled + existing credentials: straight to Accepted (1).
assert_eq!(member_status(&conn, &member_id, &org).await, 1);
}
#[rocket::async_test]
async fn post_duplicate_is_409_uniqueness() {
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-dup-org").await;
let token = seed_scim_key(&conn, &org).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "dup@example.com",
"externalId": "entra-dup-1",
});
let (auth, content_type, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
// Same externalId again.
let (auth, content_type, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["scimType"], json!("uniqueness"));
// Same email, different externalId.
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "dup@example.com",
"externalId": "entra-dup-2",
});
let (auth, content_type, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
}
#[rocket::async_test]
async fn patch_active_lifecycle_hits_correct_status_offsets() {
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-patch-org").await;
let token = seed_scim_key(&conn, &org).await;
let invited = seed_member(&conn, &org, "patch.invited@example.com", 0, MembershipType::User).await;
let confirmed = seed_member(&conn, &org, "patch.confirmed@example.com", 2, MembershipType::User).await;
let deactivate = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "Replace", "path": "active", "value": "False"}],
});
let activate = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "value": {"active": true}}],
});
// Invited (0) revokes to -128, never -1.
let (auth, ct, body) = scim_body(&token, &deactivate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(parse_json(&body_of(response).await)["active"], json!(false));
assert_eq!(member_status(&conn, &invited, &org).await, -128);
// Deprovisioning is idempotent.
let (auth, ct, body) = scim_body(&token, &deactivate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(member_status(&conn, &invited, &org).await, -128);
// Restore is lossless: straight back to Invited (0).
let (auth, ct, body) = scim_body(&token, &activate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(member_status(&conn, &invited, &org).await, 0);
// Confirmed (2) revokes to -126 and restores to 2 with akey intact.
let (auth, ct, body) = scim_body(&token, &deactivate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{confirmed}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(member_status(&conn, &confirmed, &org).await, -126);
let response = client.get(format!("/scim/v2/{org}/Users/{confirmed}")).header(bearer(&token)).dispatch().await;
assert_eq!(parse_json(&body_of(response).await)["active"], json!(false));
let (auth, ct, body) = scim_body(&token, &activate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{confirmed}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(member_status(&conn, &confirmed, &org).await, 2);
}
#[rocket::async_test]
async fn delete_revokes_and_keeps_the_row() {
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-delete-org").await;
let token = seed_scim_key(&conn, &org).await;
let member = seed_member(&conn, &org, "delete.me@example.com", 2, MembershipType::User).await;
let response = client.delete(format!("/scim/v2/{org}/Users/{member}")).header(bearer(&token)).dispatch().await;
assert_eq!(response.status(), Status::NoContent);
// The row survives (revoked), so restore stays lossless.
assert_eq!(member_status(&conn, &member, &org).await, -126);
// Idempotent.
let response = client.delete(format!("/scim/v2/{org}/Users/{member}")).header(bearer(&token)).dispatch().await;
assert_eq!(response.status(), Status::NoContent);
assert_eq!(member_status(&conn, &member, &org).await, -126);
}
#[rocket::async_test]
async fn last_confirmed_owner_cannot_be_revoked() {
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-owner-org").await;
let token = seed_scim_key(&conn, &org).await;
let owner = seed_member(&conn, &org, "sole.owner@example.com", 2, MembershipType::Owner).await;
let deactivate = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": false}],
});
let (auth, ct, body) = scim_body(&token, &deactivate);
let response =
client.patch(format!("/scim/v2/{org}/Users/{owner}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["scimType"], json!("mutability"));
assert_eq!(member_status(&conn, &owner, &org).await, 2, "owner must remain untouched");
// DELETE takes the same path.
let response = client.delete(format!("/scim/v2/{org}/Users/{owner}")).header(bearer(&token)).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
assert_eq!(member_status(&conn, &owner, &org).await, 2);
}
#[rocket::async_test]
async fn filter_round_trip_and_enumeration_shape() {
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-filter-org").await;
let token = seed_scim_key(&conn, &org).await;
seed_member(&conn, &org, "filter.target@example.com", 1, MembershipType::User).await;
// Mixed-case filter value must match the lowercased stored email.
let filter = "userName eq \"Filter.Target@Example.COM\"";
let response = client
.get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter)))
.header(bearer(&token))
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["totalResults"], json!(1));
assert_eq!(parsed["Resources"][0]["userName"], json!("filter.target@example.com"));
// A miss is an empty 200 list, not a 404: Entra's Test Connection probes
// exactly this, and a distinguishable miss would enable enumeration.
let filter = "userName eq \"nobody-here@example.com\"";
let response = client
.get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter)))
.header(bearer(&token))
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["totalResults"], json!(0));
// Unsupported grammar is a 400 invalidFilter.
let filter = "userName co \"partial\"";
let response = client
.get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter)))
.header(bearer(&token))
.dispatch()
.await;
assert_eq!(response.status(), Status::BadRequest);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["scimType"], json!("invalidFilter"));
}
#[rocket::async_test]
async fn unknown_member_and_foreign_member_are_identical_404s() {
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-404-a").await;
let token_a = seed_scim_key(&conn, &org_a).await;
let org_b = seed_org(&conn, "scim-404-b").await;
let _token_b = seed_scim_key(&conn, &org_b).await;
let foreign = seed_member(&conn, &org_b, "foreign.member@example.com", 2, MembershipType::User).await;
let response = client
.get(format!("/scim/v2/{org_a}/Users/00000000-dead-beef-0000-000000000000"))
.header(bearer(&token_a))
.dispatch()
.await;
assert_eq!(response.status(), Status::NotFound);
let unknown_body = body_of(response).await;
// Another org's member id must be indistinguishable from a nonexistent one.
let response = client.get(format!("/scim/v2/{org_a}/Users/{foreign}")).header(bearer(&token_a)).dispatch().await;
assert_eq!(response.status(), Status::NotFound);
let foreign_body = body_of(response).await;
assert_eq!(unknown_body, foreign_body);
}
#[rocket::async_test]
async fn post_inactive_creates_revoked_membership() {
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-inactive-org").await;
let token = seed_scim_key(&conn, &org).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "born.disabled@example.com",
"externalId": "entra-inactive-1",
"active": "False",
});
let (auth, ct, body) = scim_body(&token, &payload);
let response = client.post(format!("/scim/v2/{org}/Users")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["active"], json!(false));
let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into();
assert_eq!(member_status(&conn, &member_id, &org).await, -128, "invited-then-revoked offset");
}
#[rocket::async_test]
async fn patch_unsupported_path_is_invalid_path() {
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-badpatch-org").await;
let token = seed_scim_key(&conn, &org).await;
let member = seed_member(&conn, &org, "bad.patch@example.com", 1, MembershipType::User).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "displayName", "value": "New Name"}],
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.patch(format!("/scim/v2/{org}/Users/{member}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::BadRequest);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["scimType"], json!("invalidPath"));
}
fn url_escape(raw: &str) -> String {
// Percent-encode just enough for filter values in test URLs.
raw.replace('%', "%25").replace(' ', "%20").replace('"', "%22")
}

373
src/api/scim/users.rs

@ -0,0 +1,373 @@
//
// SCIM /Users endpoints. The SCIM User id is the org-membership uuid
// (MembershipId), not the global user uuid: SCIM is org-scoped and one person
// can belong to several organizations.
//
// Deprovisioning maps to REVOKE, never delete: the membership row and its
// akey survive, so restore is lossless and needs no re-confirmation. This is
// a deliberate deviation from RFC 7644 DELETE semantics, matching the
// existing Directory Connector import endpoint, and it means a compromised
// SCIM token cannot destroy memberships.
//
// Provisioning creates at most Invited (or Accepted when mail is disabled
// and the user already has credentials). Confirmed requires an admin client
// to wrap the org key for the member; no server-side path can do that.
//
use rocket::Route;
use serde_json::Value;
use crate::{
CONFIG,
api::{
EmptyResult,
core::log_event,
scim::{
ScimJson, ScimResponse,
error::ScimError,
filter::parse_eq_filter,
guard::ScimToken,
models::ScimUserRequest,
patch::{PatchOp, parse_user_patch},
},
},
db::{
DbConn,
models::{
EventType, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization,
User,
},
},
mail,
util::is_valid_email,
};
pub fn routes() -> Vec<Route> {
routes![list_users, get_user, post_user, patch_user, delete_user]
}
// Synthetic acting user recorded in the org event log for SCIM-driven
// changes, following the ACTING_ADMIN_USER precedent in the admin panel.
const SCIM_ACTOR: &str = "vaultwarden-scim-00000-000000000000";
// Device type recorded for SCIM events: 14 = UnknownBrowser, same as admin.
const SCIM_DEVICE_TYPE: i32 = 14;
// The single place the revocation encoding is interpreted: revoked statuses
// are stored as status - 128 (ACTIVATE_REVOKE_DIFF), so every stored revoked
// value is <= Revoked (-1). Never compare equality against Revoked.
fn membership_active(member: &Membership) -> bool {
member.status > MembershipStatus::Revoked as i32
}
fn to_scim_user(member: &Membership, user: &User, token: &ScimToken) -> Value {
let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid);
json!({
"schemas": [crate::api::scim::discovery::USER_SCHEMA_URN],
"id": member.uuid,
"externalId": member.external_id,
"userName": user.email,
"displayName": user.name,
"active": membership_active(member),
"emails": [{"value": user.email, "primary": true, "type": "work"}],
"meta": {
"resourceType": "User",
"location": location,
},
})
}
async fn log_scim_event(event_type: EventType, member: &Membership, token: &ScimToken, conn: &DbConn) {
log_event(
event_type as i32,
&member.uuid,
&token.org_uuid,
&SCIM_ACTOR.into(),
SCIM_DEVICE_TYPE,
&token.ip.ip,
conn,
)
.await;
}
fn list_response(total: usize, start_index: usize, resources: &[Value]) -> ScimResponse {
ScimResponse::ok(json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": total,
"itemsPerPage": resources.len(),
"startIndex": start_index,
"Resources": resources,
}))
}
#[derive(FromForm)]
pub struct ListParams {
filter: Option<String>,
#[field(name = "startIndex")]
start_index: Option<i64>,
count: Option<i64>,
}
#[get("/v2/<_>/Users?<params..>")]
async fn list_users(params: ListParams, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> {
// Resolve the matching memberships first, then paginate.
let members: Vec<Membership> = if let Some(raw_filter) = params.filter.as_deref() {
let eq = parse_eq_filter(raw_filter)?;
match eq.attribute.as_str() {
// find_by_mail lowercases internally, so a mixed-case userName
// from Entra still matches the lowercased stored email.
"username" | "emails.value" => match User::find_by_mail(&eq.value, &conn).await {
Some(user) => {
Membership::find_by_user_and_org(&user.uuid, &token.org_uuid, &conn).await.into_iter().collect()
}
None => Vec::new(),
},
"externalid" => {
Membership::find_by_external_id_and_org(&eq.value, &token.org_uuid, &conn).await.into_iter().collect()
}
_ => {
return Err(ScimError::bad_request(
"invalidFilter",
"Filterable attributes are userName and externalId",
));
}
}
} else {
Membership::find_by_org(&token.org_uuid, &conn).await
};
let total = members.len();
let start_index = usize::try_from(params.start_index.unwrap_or(1)).unwrap_or(1).max(1);
let count = usize::try_from(params.count.unwrap_or(100)).unwrap_or(0).min(200);
let mut resources = Vec::new();
for member in members.into_iter().skip(start_index - 1).take(count) {
// A membership always references a user; a missing row would be a
// dangling foreign key, so surface it as a 500 rather than skip.
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
return Err(ScimError::internal());
};
resources.push(to_scim_user(&member, &user, &token));
}
Ok(list_response(total, start_index, &resources))
}
#[get("/v2/<_>/Users/<member_id>")]
async fn get_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> {
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else {
return Err(ScimError::not_found());
};
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
return Err(ScimError::internal());
};
Ok(ScimResponse::ok(to_scim_user(&member, &user, &token)))
}
#[post("/v2/<_>/Users", data = "<data>")]
async fn post_user(data: ScimJson<ScimUserRequest>, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> {
let request = data.0;
let Some(email) = request.email() else {
return Err(ScimError::bad_request("invalidValue", "userName (or a primary email) must be an email address"));
};
if !is_valid_email(email) {
return Err(ScimError::bad_request("invalidValue", "userName is not a valid email address"));
}
// Uniqueness: by externalId and by email, both scoped to the org.
if let Some(external_id) = request.external_id.as_deref()
&& Membership::find_by_external_id_and_org(external_id, &token.org_uuid, &conn).await.is_some()
{
return Err(ScimError::conflict("uniqueness", "A member with this externalId already exists"));
}
if Membership::find_by_email_and_org(email, &token.org_uuid, &conn).await.is_some() {
return Err(ScimError::conflict("uniqueness", "A member with this userName already exists"));
}
let Some(org) = Organization::find_by_uuid(&token.org_uuid, &conn).await else {
return Err(ScimError::internal());
};
// Mirrors the Directory Connector import: link an existing account by
// email, or create a shell account (no keypair yet) that the invite email
// lets the person register.
let mut user_created = false;
let user = if let Some(user) = User::find_by_mail(email, &conn).await {
user
} else {
let mut new_user = User::new(email, request.display_name());
new_user.save(&conn).await.map_err(|_| ScimError::internal())?;
if !CONFIG.mail_enabled() {
Invitation::new(&new_user.email).save(&conn).await.map_err(|_| ScimError::internal())?;
}
user_created = true;
new_user
};
// Reaching Accepted directly is only possible when no invite mail will be
// sent and the account already has credentials to log in with.
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
MembershipStatus::Invited as i32
} else {
MembershipStatus::Accepted as i32
};
// RFC 7644 allows creating a resource already deactivated; store the
// membership pre-revoked and send no invite mail in that case.
let create_active = request.active.is_none_or(|b| b.0);
let mut member = Membership::new(user.uuid.clone(), token.org_uuid.clone(), Some(org.billing_email.clone()));
member.set_external_id(request.external_id.clone());
member.access_all = false;
member.atype = MembershipType::User as i32;
member.status = member_status;
if !create_active {
member.revoke();
}
member.save(&conn).await.map_err(|_| ScimError::internal())?;
if create_active
&& CONFIG.mail_enabled()
&& let Err(e) = mail::send_invite(
&user,
token.org_uuid.clone(),
member.uuid.clone(),
&org.name,
Some(org.billing_email.clone()),
)
.await
{
error!("SCIM provisioning rollback, invite mail failed: {e:#?}");
let rollback: EmptyResult = if user_created {
user.delete(&conn).await
} else {
member.delete(&conn).await
};
drop(rollback);
return Err(ScimError::internal());
}
log_scim_event(EventType::OrganizationUserInvited, &member, &token, &conn).await;
if !create_active {
log_scim_event(EventType::OrganizationUserRevoked, &member, &token, &conn).await;
}
let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid);
Ok(ScimResponse::created(location, to_scim_user(&member, &user, &token)))
}
#[patch("/v2/<_>/Users/<member_id>", data = "<data>")]
async fn patch_user(
member_id: MembershipId,
data: ScimJson<PatchOp>,
token: ScimToken,
conn: DbConn,
) -> Result<ScimResponse, ScimError> {
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else {
return Err(ScimError::not_found());
};
let patch = parse_user_patch(&data.0)?;
let Some(desired_active) = patch.active else {
return Err(ScimError::bad_request("invalidValue", "No supported attributes in patch (supported: active)"));
};
if desired_active {
restore_member(&mut member, &token, &conn).await?;
} else {
revoke_member(&mut member, &token, &conn).await?;
}
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
return Err(ScimError::internal());
};
Ok(ScimResponse::ok(to_scim_user(&member, &user, &token)))
}
// DELETE deprovisions by revoking, identically to PATCH active:false. The
// membership row is kept: see the module comment.
#[delete("/v2/<_>/Users/<member_id>")]
async fn delete_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> {
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else {
return Err(ScimError::not_found());
};
revoke_member(&mut member, &token, &conn).await?;
Ok(ScimResponse::no_content())
}
async fn revoke_member(member: &mut Membership, token: &ScimToken, conn: &DbConn) -> Result<(), ScimError> {
if !membership_active(member) {
// Already revoked: deprovisioning is idempotent.
return Ok(());
}
// Never leave an organization without a confirmed owner.
if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
&& Membership::count_confirmed_by_org_and_type(&token.org_uuid, MembershipType::Owner, conn).await <= 1
{
return Err(ScimError::conflict("mutability", "Cannot revoke the last confirmed owner of the organization"));
}
member.revoke();
member.save(conn).await.map_err(|_| ScimError::internal())?;
log_scim_event(EventType::OrganizationUserRevoked, member, token, conn).await;
Ok(())
}
async fn restore_member(member: &mut Membership, token: &ScimToken, conn: &DbConn) -> Result<(), ScimError> {
if membership_active(member) {
return Ok(());
}
member.restore();
// Policy check runs on the restored status, mirroring restore_member_impl.
if OrgPolicy::check_user_allowed(member, "restore", conn).await.is_err() {
return Err(ScimError::bad_request("invalidValue", "Restore is blocked by an organization policy"));
}
member.save(conn).await.map_err(|_| ScimError::internal())?;
log_scim_event(EventType::OrganizationUserRestored, member, token, conn).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn member_with_status(status: i32) -> Membership {
let mut member = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
member.status = status;
member
}
#[test]
fn active_mapping_handles_revocation_offsets() {
// Stored revoked values are status - 128; Revoked (-1) itself is a
// sentinel that never hits the database.
for (status, expected_active) in [(2, true), (1, true), (0, true), (-126, false), (-127, false), (-128, false)]
{
assert_eq!(membership_active(&member_with_status(status)), expected_active, "status {status}");
}
}
#[test]
fn revoke_restore_arithmetic_round_trips() {
for initial in [0, 1, 2] {
let mut member = member_with_status(initial);
assert!(member.revoke());
assert_eq!(member.status, initial - 128);
assert!(!member.revoke(), "revoke must be idempotent");
assert!(member.restore());
assert_eq!(member.status, initial);
assert!(!member.restore(), "restore must be idempotent");
}
}
#[test]
fn from_i32_still_rejects_revoked_values() {
// Regression canary: OrgHeaders relies on revoked statuses mapping to
// None. If upstream ever changes this, revisit membership_active().
assert!(MembershipStatus::from_i32(-126).is_none());
assert!(MembershipStatus::from_i32(-128).is_none());
assert!(MembershipStatus::from_i32(-1).is_none());
}
}
Loading…
Cancel
Save