mod filter;
mod resources;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
routing::get,
};
use fraiseql_auth::{
SessionStore,
scim::{PgScimStore, PgScimTokenStore, ScimPrincipal, ScimStore as _, ScimUserWrite},
};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
pub use self::resources::SCIM_CONTENT_TYPE;
use self::{
filter::{expect_attribute, parse_eq},
resources::{
GROUP_SCHEMA, GroupBody, PATCH_OP_SCHEMA, PatchBody, USER_SCHEMA, UserBody, error_response,
etag, group_to_json, list_response, project, resource_types, schemas,
service_provider_config, user_to_json,
},
};
use crate::api::rbac_management::db_backend::RbacDbBackend;
const MAX_COUNT: i64 = 200;
const DEFAULT_COUNT: i64 = 100;
#[derive(Clone)]
pub struct ScimState {
pub pool: sqlx::PgPool,
pub tokens: Arc<PgScimTokenStore>,
pub session_store: Arc<dyn SessionStore>,
pub rbac: Arc<RbacDbBackend>,
pub base_url: String,
}
impl std::fmt::Debug for ScimState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScimState")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
fn scim_error(status: StatusCode, detail: &str, scim_type: Option<&str>) -> Response {
(
status,
[(header::CONTENT_TYPE, SCIM_CONTENT_TYPE)],
Json(error_response(status.as_u16(), detail, scim_type)),
)
.into_response()
}
fn scim_json(status: StatusCode, body: Value, version: Option<i64>) -> Response {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(SCIM_CONTENT_TYPE));
if let Some(version) = version {
if let Ok(value) = header::HeaderValue::from_str(&etag(version)) {
headers.insert(header::ETAG, value);
}
}
(status, headers, Json(body)).into_response()
}
fn store_error(e: &fraiseql_auth::AuthError) -> Response {
use fraiseql_auth::AuthError;
match e {
AuthError::TokenNotFound => scim_error(StatusCode::NOT_FOUND, "Resource not found", None),
AuthError::EmailAlreadyRegistered => scim_error(
StatusCode::CONFLICT,
"A resource with this userName or displayName already exists",
Some("uniqueness"),
),
_ => {
tracing::error!(error = %e, "SCIM store operation failed");
scim_error(StatusCode::INTERNAL_SERVER_ERROR, "Provisioning store error", None)
},
}
}
async fn scim_auth_middleware(
State(state): State<ScimState>,
mut request: axum::extract::Request,
next: Next,
) -> Response {
let token = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
.map(|(_, token)| token.trim().to_string());
let Some(token) = token else {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
Json(error_response(401, "Provisioning bearer token required", None)),
)
.into_response();
};
match state.tokens.authenticate(&token).await {
Ok(principal) => {
request.extensions_mut().insert(principal);
next.run(request).await
},
Err(e) => {
tracing::warn!(error = %e, "SCIM provisioning token rejected");
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
Json(error_response(401, "Invalid provisioning token", None)),
)
.into_response()
},
}
}
pub fn scim_router(state: ScimState) -> Router {
Router::new()
.route("/scim/v2/Users", get(list_users).post(create_user))
.route(
"/scim/v2/Users/{id}",
get(get_user).put(replace_user).patch(patch_user).delete(delete_user),
)
.route("/scim/v2/Groups", get(list_groups).post(create_group))
.route(
"/scim/v2/Groups/{id}",
get(get_group).put(replace_group).patch(patch_group).delete(delete_group),
)
.route("/scim/v2/ServiceProviderConfig", get(get_service_provider_config))
.route("/scim/v2/ResourceTypes", get(get_resource_types))
.route("/scim/v2/ResourceTypes/{id}", get(get_resource_type))
.route("/scim/v2/Schemas", get(get_schemas))
.route("/scim/v2/Schemas/{id}", get(get_schema))
.route("/scim/v2/Users/.search", axum::routing::post(search_users))
.route("/scim/v2/Groups/.search", axum::routing::post(search_groups))
.route("/scim/v2/.search", axum::routing::post(search_all))
.fallback(scim_not_found)
.method_not_allowed_fallback(scim_method_not_allowed)
.route_layer(axum::middleware::from_fn_with_state(state.clone(), scim_auth_middleware))
.with_state(state)
}
async fn scim_method_not_allowed() -> Response {
scim_error(
StatusCode::METHOD_NOT_ALLOWED,
"That method is not supported on this SCIM endpoint",
None,
)
}
async fn scim_not_found() -> Response {
scim_error(StatusCode::NOT_FOUND, "No such SCIM endpoint", None)
}
fn store_for(state: &ScimState, principal: &ScimPrincipal) -> PgScimStore {
PgScimStore::new(state.pool.clone(), principal.tenant_id)
}
#[derive(Debug, Deserialize)]
struct ListQuery {
#[serde(default)]
filter: Option<String>,
#[serde(default, rename = "startIndex")]
start_index: Option<i64>,
#[serde(default)]
count: Option<i64>,
#[serde(default)]
attributes: Option<String>,
#[serde(default, rename = "excludedAttributes")]
excluded: Option<String>,
}
impl ListQuery {
const fn start(&self) -> i64 {
match self.start_index {
Some(i) if i > 1 => i,
_ => 1,
}
}
const fn count(&self) -> i64 {
match self.count {
Some(c) if c < 0 => 0,
Some(c) if c > MAX_COUNT => MAX_COUNT,
Some(c) => c,
None => DEFAULT_COUNT,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SearchBody {
#[serde(default)]
filter: Option<String>,
#[serde(default)]
start_index: Option<i64>,
#[serde(default)]
count: Option<i64>,
#[serde(default, deserialize_with = "attribute_list")]
attributes: Option<String>,
#[serde(default, deserialize_with = "attribute_list")]
excluded_attributes: Option<String>,
}
fn attribute_list<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrList {
String(String),
List(Vec<String>),
}
Ok(Option::<StringOrList>::deserialize(deserializer)?.map(|value| match value {
StringOrList::String(one) => one,
StringOrList::List(many) => many.join(","),
}))
}
impl From<SearchBody> for ListQuery {
fn from(body: SearchBody) -> Self {
Self {
filter: body.filter,
start_index: body.start_index,
count: body.count,
attributes: body.attributes,
excluded: body.excluded_attributes,
}
}
}
async fn search_users(
state: State<ScimState>,
principal: axum::Extension<ScimPrincipal>,
Json(body): Json<SearchBody>,
) -> Response {
list_users(state, principal, Query(body.into())).await
}
async fn search_groups(
state: State<ScimState>,
principal: axum::Extension<ScimPrincipal>,
Json(body): Json<SearchBody>,
) -> Response {
list_groups(state, principal, Query(body.into())).await
}
async fn search_all(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Json(body): Json<SearchBody>,
) -> Response {
let q: ListQuery = body.into();
if q.filter.is_some() {
return scim_error(
StatusCode::BAD_REQUEST,
"a filter on the root .search is not supported; POST to /Users/.search or \
/Groups/.search instead",
Some("invalidFilter"),
);
}
let store = store_for(&state, &principal);
let users = match store.list_users(None, q.start(), q.count()).await {
Ok(page) => page,
Err(e) => return store_error(&e),
};
let groups = match store.list_groups(None, q.start(), q.count()).await {
Ok(page) => page,
Err(e) => return store_error(&e),
};
let mut resources: Vec<Value> =
Vec::with_capacity(users.resources.len() + groups.resources.len());
for user in &users.resources {
let user_groups = store.groups_of_user(&user.id).await.unwrap_or_default();
resources.push(project(
user_to_json(user, &state.base_url, &user_groups),
q.attributes.as_deref(),
q.excluded.as_deref(),
));
}
for group in &groups.resources {
resources.push(project(
group_to_json(group, &state.base_url),
q.attributes.as_deref(),
q.excluded.as_deref(),
));
}
let total = users.total_results + groups.total_results;
scim_json(StatusCode::OK, list_response(&resources, total, q.start()), None)
}
async fn list_users(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Query(q): Query<ListQuery>,
) -> Response {
let user_name = match q.filter.as_deref() {
None => None,
Some(raw) => match parse_eq(raw).and_then(|f| expect_attribute(&f, "userName")) {
Ok(value) => Some(value),
Err(e) => return scim_error(StatusCode::BAD_REQUEST, &e.0, Some("invalidFilter")),
},
};
let store = store_for(&state, &principal);
let page = match store.list_users(user_name.as_deref(), q.start(), q.count()).await {
Ok(page) => page,
Err(e) => return store_error(&e),
};
let mut resources = Vec::with_capacity(page.resources.len());
for user in &page.resources {
let groups = store.groups_of_user(&user.id).await.unwrap_or_default();
resources.push(project(
user_to_json(user, &state.base_url, &groups),
q.attributes.as_deref(),
q.excluded.as_deref(),
));
}
scim_json(StatusCode::OK, list_response(&resources, page.total_results, q.start()), None)
}
async fn get_user(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
Query(q): Query<ListQuery>,
) -> Response {
let store = store_for(&state, &principal);
match store.get_user(&id).await {
Ok(Some(user)) => {
let groups = store.groups_of_user(&user.id).await.unwrap_or_default();
scim_json(
StatusCode::OK,
project(
user_to_json(&user, &state.base_url, &groups),
q.attributes.as_deref(),
q.excluded.as_deref(),
),
Some(user.version),
)
},
Ok(None) => scim_error(StatusCode::NOT_FOUND, "User not found", None),
Err(e) => store_error(&e),
}
}
async fn create_user(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Json(body): Json<UserBody>,
) -> Response {
let Some(user_name) = body.user_name.clone().filter(|u| !u.trim().is_empty()) else {
return scim_error(StatusCode::BAD_REQUEST, "userName is required", Some("invalidValue"));
};
let write = ScimUserWrite {
user_name,
external_id: body.external_id.clone(),
email: body.primary_email(),
given_name: body.name.as_ref().and_then(|n| n.given_name.clone()),
family_name: body.name.as_ref().and_then(|n| n.family_name.clone()),
display_name: body.display_name.clone(),
active: body.active.unwrap_or(true),
};
let store = store_for(&state, &principal);
match store.create_user(&write).await {
Ok(user) => scim_json(
StatusCode::CREATED,
user_to_json(&user, &state.base_url, &[]),
Some(user.version),
),
Err(e) => store_error(&e),
}
}
async fn replace_user(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<UserBody>,
) -> Response {
let store = store_for(&state, &principal);
let Some(existing) = (match store.get_user(&id).await {
Ok(user) => user,
Err(e) => return store_error(&e),
}) else {
return scim_error(StatusCode::NOT_FOUND, "User not found", None);
};
if let Some(response) = precondition_failed(&headers, existing.version) {
return response;
}
let Some(user_name) = body.user_name.clone().filter(|u| !u.trim().is_empty()) else {
return scim_error(StatusCode::BAD_REQUEST, "userName is required", Some("invalidValue"));
};
let write = ScimUserWrite {
user_name,
external_id: body.external_id.clone(),
email: body.primary_email(),
given_name: body.name.as_ref().and_then(|n| n.given_name.clone()),
family_name: body.name.as_ref().and_then(|n| n.family_name.clone()),
display_name: body.display_name.clone(),
active: body.active.unwrap_or(true),
};
match store.replace_user(&id, &write).await {
Ok(user) => {
if existing.active && !user.active {
revoke_sessions(&state, &user.id).await;
}
let groups = store.groups_of_user(&user.id).await.unwrap_or_default();
scim_json(
StatusCode::OK,
user_to_json(&user, &state.base_url, &groups),
Some(user.version),
)
},
Err(e) => store_error(&e),
}
}
async fn patch_user(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<PatchBody>,
) -> Response {
if let Some(response) = wrong_patch_schema(&body) {
return response;
}
let store = store_for(&state, &principal);
let Some(existing) = (match store.get_user(&id).await {
Ok(user) => user,
Err(e) => return store_error(&e),
}) else {
return scim_error(StatusCode::NOT_FOUND, "User not found", None);
};
if let Some(response) = precondition_failed(&headers, existing.version) {
return response;
}
let mut write = ScimUserWrite {
user_name: existing.user_name.clone(),
external_id: existing.external_id.clone(),
email: existing.email.clone(),
given_name: existing.given_name.clone(),
family_name: existing.family_name.clone(),
display_name: existing.display_name.clone(),
active: existing.active,
};
for op in &body.operations {
if let Err(detail) = apply_user_op(&mut write, op) {
return scim_error(StatusCode::BAD_REQUEST, &detail, Some("invalidPath"));
}
}
match store.replace_user(&id, &write).await {
Ok(user) => {
if existing.active && !user.active {
revoke_sessions(&state, &user.id).await;
}
let groups = store.groups_of_user(&user.id).await.unwrap_or_default();
scim_json(
StatusCode::OK,
user_to_json(&user, &state.base_url, &groups),
Some(user.version),
)
},
Err(e) => store_error(&e),
}
}
fn wrong_patch_schema(body: &PatchBody) -> Option<Response> {
if body.schemas.iter().any(|s| s == PATCH_OP_SCHEMA) {
return None;
}
Some(scim_error(
StatusCode::BAD_REQUEST,
&format!("a PatchOp body must declare the \"{PATCH_OP_SCHEMA}\" schema"),
Some("invalidSyntax"),
))
}
fn apply_user_op(write: &mut ScimUserWrite, op: &resources::PatchOperation) -> Result<(), String> {
let removing = op.op.eq_ignore_ascii_case("remove");
if let Some(path) = op.path.as_deref().map(str::trim) {
let value = if removing { None } else { op.value.as_ref() };
set_user_attribute(write, path, value)
} else {
let Some(object) = op.value.as_ref().and_then(Value::as_object) else {
return Err("a `PatchOp` without a path needs an object value".to_string());
};
for (key, value) in object {
set_user_attribute(write, key, Some(value))?;
}
Ok(())
}
}
fn set_user_attribute(
write: &mut ScimUserWrite,
path: &str,
value: Option<&Value>,
) -> Result<(), String> {
fn text(value: Option<&Value>, field: &str) -> Result<Option<String>, String> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("'{field}' requires a string value")),
}
}
match path.to_ascii_lowercase().as_str() {
"active" => {
write.active = match value {
None | Some(Value::Null) => true,
Some(Value::Bool(b)) => *b,
Some(_) => return Err("'active' requires a boolean value".to_string()),
};
},
"username" => {
write.user_name =
text(value, "userName")?.ok_or("userName may not be removed".to_string())?;
},
"externalid" => write.external_id = text(value, "externalId")?,
"displayname" => write.display_name = text(value, "displayName")?,
"name.givenname" => write.given_name = text(value, "name.givenName")?,
"name.familyname" => write.family_name = text(value, "name.familyName")?,
"name" => {
let object = value.and_then(Value::as_object);
write.given_name = object
.and_then(|o| o.get("givenName"))
.and_then(Value::as_str)
.map(str::to_string);
write.family_name = object
.and_then(|o| o.get("familyName"))
.and_then(Value::as_str)
.map(str::to_string);
},
"emails" => {
write.email = value
.and_then(Value::as_array)
.and_then(|entries| {
entries
.iter()
.find(|e| e.get("primary").and_then(Value::as_bool) == Some(true))
.or_else(|| entries.first())
})
.and_then(|e| e.get("value"))
.and_then(Value::as_str)
.map(str::to_string);
},
other => {
return Err(format!(
"unsupported PatchOp path '{other}' on User: supported paths are active, \
userName, externalId, displayName, name, name.givenName, name.familyName, emails"
));
},
}
Ok(())
}
async fn delete_user(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
) -> Response {
let store = store_for(&state, &principal);
match store.delete_user(&id).await {
Ok(()) => {
revoke_sessions(&state, &id).await;
StatusCode::NO_CONTENT.into_response()
},
Err(e) => store_error(&e),
}
}
async fn revoke_sessions(state: &ScimState, user_id: &str) {
if let Err(e) = state.session_store.revoke_all_sessions(user_id).await {
tracing::error!(
user_id = %user_id, error = %e,
"SCIM deactivation could not revoke existing sessions; the account is deactivated \
and new sessions are refused, but live refresh tokens may survive until expiry"
);
} else {
tracing::info!(user_id = %user_id, "SCIM deactivation revoked all sessions");
}
}
async fn list_groups(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Query(q): Query<ListQuery>,
) -> Response {
let display_name = match q.filter.as_deref() {
None => None,
Some(raw) => match parse_eq(raw).and_then(|f| expect_attribute(&f, "displayName")) {
Ok(value) => Some(value),
Err(e) => return scim_error(StatusCode::BAD_REQUEST, &e.0, Some("invalidFilter")),
},
};
let store = store_for(&state, &principal);
match store.list_groups(display_name.as_deref(), q.start(), q.count()).await {
Ok(page) => {
let resources = page
.resources
.iter()
.map(|g| {
project(
group_to_json(g, &state.base_url),
q.attributes.as_deref(),
q.excluded.as_deref(),
)
})
.collect::<Vec<_>>();
scim_json(
StatusCode::OK,
list_response(&resources, page.total_results, q.start()),
None,
)
},
Err(e) => store_error(&e),
}
}
async fn get_group(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
Query(q): Query<ListQuery>,
) -> Response {
let Ok(uuid) = Uuid::parse_str(&id) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
match store_for(&state, &principal).get_group(uuid).await {
Ok(Some(group)) => scim_json(
StatusCode::OK,
project(
group_to_json(&group, &state.base_url),
q.attributes.as_deref(),
q.excluded.as_deref(),
),
Some(group.version),
),
Ok(None) => scim_error(StatusCode::NOT_FOUND, "Group not found", None),
Err(e) => store_error(&e),
}
}
async fn create_group(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Json(body): Json<GroupBody>,
) -> Response {
let Some(display_name) = body.display_name.clone().filter(|d| !d.trim().is_empty()) else {
return scim_error(
StatusCode::BAD_REQUEST,
"displayName is required",
Some("invalidValue"),
);
};
let members = body.member_ids();
let store = store_for(&state, &principal);
match store.create_group(&display_name, body.external_id.as_deref(), &members).await {
Ok(group) => {
mirror_group_to_rbac(&state, &principal, &group.display_name, &members, &[]).await;
scim_json(
StatusCode::CREATED,
group_to_json(&group, &state.base_url),
Some(group.version),
)
},
Err(e) => store_error(&e),
}
}
async fn replace_group(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<GroupBody>,
) -> Response {
let Ok(uuid) = Uuid::parse_str(&id) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
let store = store_for(&state, &principal);
let Some(existing) = (match store.get_group(uuid).await {
Ok(group) => group,
Err(e) => return store_error(&e),
}) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
if let Some(response) = precondition_failed(&headers, existing.version) {
return response;
}
let Some(display_name) = body.display_name.clone().filter(|d| !d.trim().is_empty()) else {
return scim_error(
StatusCode::BAD_REQUEST,
"displayName is required",
Some("invalidValue"),
);
};
let members = body.member_ids();
match store
.replace_group(uuid, &display_name, body.external_id.as_deref(), &members)
.await
{
Ok(group) => {
let removed: Vec<String> =
existing.members.iter().filter(|m| !members.contains(m)).cloned().collect();
mirror_group_to_rbac(&state, &principal, &group.display_name, &members, &removed).await;
scim_json(StatusCode::OK, group_to_json(&group, &state.base_url), Some(group.version))
},
Err(e) => store_error(&e),
}
}
async fn patch_group(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<PatchBody>,
) -> Response {
if let Some(response) = wrong_patch_schema(&body) {
return response;
}
let Ok(uuid) = Uuid::parse_str(&id) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
let store = store_for(&state, &principal);
let Some(existing) = (match store.get_group(uuid).await {
Ok(group) => group,
Err(e) => return store_error(&e),
}) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
if let Some(response) = precondition_failed(&headers, existing.version) {
return response;
}
let mut add: Vec<String> = Vec::new();
let mut remove: Vec<String> = Vec::new();
let mut display_name = existing.display_name.clone();
let mut external_id = existing.external_id.clone();
let mut membership_touched = false;
for op in &body.operations {
let removing = op.op.eq_ignore_ascii_case("remove");
let replacing = op.op.eq_ignore_ascii_case("replace");
if !removing && !replacing && !op.op.eq_ignore_ascii_case("add") {
return scim_error(
StatusCode::BAD_REQUEST,
&format!("unsupported PatchOp op '{}'", op.op),
Some("invalidSyntax"),
);
}
let Some(raw_path) = op.path.as_deref().map(str::trim) else {
let Some(object) = op.value.as_ref().and_then(Value::as_object) else {
return scim_error(
StatusCode::BAD_REQUEST,
"a PatchOp without a path needs an object value",
Some("invalidValue"),
);
};
for (key, value) in object {
match key.to_ascii_lowercase().as_str() {
"displayname" => {
display_name = value.as_str().map_or_else(String::new, str::to_string);
},
"externalid" => external_id = value.as_str().map(str::to_string),
"members" => {
membership_touched = true;
remove.extend(existing.members.iter().cloned());
add.extend(member_ids_from(Some(value)));
},
other => {
return scim_error(
StatusCode::BAD_REQUEST,
&format!("unsupported PatchOp path '{other}' on Group"),
Some("invalidPath"),
);
},
}
}
continue;
};
let lowered = raw_path.to_ascii_lowercase();
let targeted = lowered.strip_prefix("members[").and_then(|rest| {
rest.strip_suffix(']').and_then(|inner| parse_eq(inner).ok().map(|f| f.value))
});
match lowered.as_str() {
"displayname" => {
if removing {
return scim_error(
StatusCode::BAD_REQUEST,
"displayName is required and may not be removed",
Some("invalidValue"),
);
}
let Some(value) = op.value.as_ref().and_then(Value::as_str) else {
return scim_error(
StatusCode::BAD_REQUEST,
"'displayName' requires a string value",
Some("invalidValue"),
);
};
display_name = value.to_string();
},
"externalid" => {
external_id = if removing {
None
} else {
op.value.as_ref().and_then(Value::as_str).map(str::to_string)
};
},
path if path.starts_with("members") => {
membership_touched = true;
let ids =
targeted.map_or_else(|| member_ids_from(op.value.as_ref()), |id| vec![id]);
if removing {
if ids.is_empty() {
remove.extend(existing.members.iter().cloned());
} else {
remove.extend(ids);
}
} else {
if replacing {
remove.extend(existing.members.iter().cloned());
}
add.extend(ids);
}
},
other => {
return scim_error(
StatusCode::BAD_REQUEST,
&format!(
"unsupported PatchOp path '{other}' on Group: supported paths are \
displayName, externalId and members"
),
Some("invalidPath"),
);
},
}
}
if display_name != existing.display_name || external_id != existing.external_id {
let members: Vec<String> = if membership_touched {
let mut next: Vec<String> =
existing.members.iter().filter(|m| !remove.contains(m)).cloned().collect();
for id in &add {
if !next.contains(id) {
next.push(id.clone());
}
}
next
} else {
existing.members.clone()
};
return match store
.replace_group(uuid, &display_name, external_id.as_deref(), &members)
.await
{
Ok(group) => {
mirror_group_to_rbac(&state, &principal, &group.display_name, &add, &remove).await;
scim_json(
StatusCode::OK,
group_to_json(&group, &state.base_url),
Some(group.version),
)
},
Err(e) => store_error(&e),
};
}
match store.patch_group_members(uuid, &add, &remove).await {
Ok(group) => {
mirror_group_to_rbac(&state, &principal, &group.display_name, &add, &remove).await;
scim_json(StatusCode::OK, group_to_json(&group, &state.base_url), Some(group.version))
},
Err(e) => store_error(&e),
}
}
async fn delete_group(
State(state): State<ScimState>,
axum::Extension(principal): axum::Extension<ScimPrincipal>,
Path(id): Path<String>,
) -> Response {
let Ok(uuid) = Uuid::parse_str(&id) else {
return scim_error(StatusCode::NOT_FOUND, "Group not found", None);
};
let store = store_for(&state, &principal);
let existing = store.get_group(uuid).await.ok().flatten();
match store.delete_group(uuid).await {
Ok(()) => {
if let Some(group) = existing {
mirror_group_to_rbac(&state, &principal, &group.display_name, &[], &group.members)
.await;
}
StatusCode::NO_CONTENT.into_response()
},
Err(e) => store_error(&e),
}
}
fn member_ids_from(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|e| e.get("value").and_then(Value::as_str).map(str::to_string))
.collect()
})
.unwrap_or_default()
}
async fn mirror_group_to_rbac(
state: &ScimState,
principal: &ScimPrincipal,
display_name: &str,
add: &[String],
remove: &[String],
) {
let tenant = principal.tenant_id.map(|t| t.to_string());
let Some(role_id) = ensure_role(state, display_name, tenant.as_deref()).await else {
return;
};
for user_id in add {
if let Err(e) = state.rbac.assign_role_to_user(user_id, &role_id, tenant.as_deref()).await {
tracing::error!(
user_id = %user_id, group = %display_name, error = %e,
"SCIM group membership could not be mirrored onto the RBAC role"
);
}
}
for user_id in remove {
if let Err(e) = state.rbac.revoke_role_from_user(user_id, &role_id).await {
tracing::error!(
user_id = %user_id, group = %display_name, error = %e,
"SCIM group removal could not be mirrored onto the RBAC role"
);
}
}
}
async fn ensure_role(
state: &ScimState,
display_name: &str,
tenant: Option<&str>,
) -> Option<String> {
match state.rbac.list_roles(tenant, 1000, 0).await {
Ok(page) => {
if let Some(role) = page.items.iter().find(|r| r.name == display_name) {
return Some(role.id.clone());
}
},
Err(e) => {
tracing::error!(error = %e, "could not list RBAC roles for SCIM group mirroring");
return None;
},
}
match state
.rbac
.create_role(
display_name,
Some("Created by SCIM group provisioning; permissions are granted by an admin"),
Vec::new(),
tenant,
)
.await
{
Ok(role) => Some(role.id),
Err(e) => {
tracing::error!(
group = %display_name, error = %e,
"could not create the RBAC role mirroring a SCIM group"
);
None
},
}
}
fn precondition_failed(headers: &HeaderMap, version: i64) -> Option<Response> {
let if_match = headers.get(header::IF_MATCH)?.to_str().ok()?.trim().to_string();
if if_match == "*" {
return None;
}
let current = etag(version);
let matches = if_match.split(',').map(str::trim).any(|candidate| {
candidate == current
|| candidate.trim_start_matches("W/") == current.trim_start_matches("W/")
});
if matches {
return None;
}
Some(scim_error(
StatusCode::PRECONDITION_FAILED,
"The resource has changed since the version named in If-Match",
None,
))
}
async fn get_service_provider_config(State(state): State<ScimState>) -> Response {
scim_json(StatusCode::OK, service_provider_config(&state.base_url), None)
}
async fn get_resource_types(State(state): State<ScimState>) -> Response {
let types = resource_types(&state.base_url);
let total = i64::try_from(types.len()).unwrap_or(i64::MAX);
scim_json(StatusCode::OK, list_response(&types, total, 1), None)
}
async fn get_resource_type(State(state): State<ScimState>, Path(id): Path<String>) -> Response {
resource_types(&state.base_url)
.into_iter()
.find(|t| t.get("id").and_then(Value::as_str) == Some(id.as_str()))
.map_or_else(
|| scim_error(StatusCode::NOT_FOUND, "ResourceType not found", None),
|t| scim_json(StatusCode::OK, t, None),
)
}
async fn get_schemas(State(state): State<ScimState>) -> Response {
let all = schemas(&state.base_url);
let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
scim_json(StatusCode::OK, list_response(&all, total, 1), None)
}
async fn get_schema(State(state): State<ScimState>, Path(id): Path<String>) -> Response {
let wanted = match id.as_str() {
"User" => USER_SCHEMA,
"Group" => GROUP_SCHEMA,
other => other,
};
schemas(&state.base_url)
.into_iter()
.find(|s| s.get("id").and_then(Value::as_str) == Some(wanted))
.map_or_else(
|| scim_error(StatusCode::NOT_FOUND, "Schema not found", None),
|s| scim_json(StatusCode::OK, s, None),
)
}
#[must_use]
pub fn scim_base_paths() -> Vec<&'static str> {
vec![
"/scim/v2/Users",
"/scim/v2/Groups",
"/scim/v2/ServiceProviderConfig",
"/scim/v2/ResourceTypes",
"/scim/v2/Schemas",
]
}
pub use fraiseql_auth::scim::MintedScimToken;
#[cfg(test)]
mod tests;
#[derive(Clone)]
pub struct ScimTokenManagementState {
pub tokens: Arc<PgScimTokenStore>,
}
impl std::fmt::Debug for ScimTokenManagementState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScimTokenManagementState").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct MintScimTokenRequest {
pub idp_name: String,
#[serde(default)]
pub tenant_id: Option<Uuid>,
#[serde(default)]
pub description: Option<String>,
}
pub fn scim_token_management_router(state: ScimTokenManagementState) -> Router {
Router::new()
.route("/api/scim/tokens", axum::routing::post(mint_token).get(list_tokens))
.route("/api/scim/tokens/{id}", axum::routing::delete(revoke_token))
.with_state(Arc::new(state))
}
async fn mint_token(
State(state): State<Arc<ScimTokenManagementState>>,
Json(payload): Json<MintScimTokenRequest>,
) -> Response {
match state
.tokens
.mint(&payload.idp_name, payload.tenant_id, payload.description.as_deref())
.await
{
Ok(minted) => (
StatusCode::CREATED,
Json(json!({
"id": minted.record.id,
"idp_name": minted.record.idp_name,
"tenant_id": minted.record.tenant_id,
"description": minted.record.description,
"created_at": minted.record.created_at,
"token": minted.token,
})),
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "could not mint a SCIM provisioning token");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "could not mint provisioning token" })),
)
.into_response()
},
}
}
async fn list_tokens(State(state): State<Arc<ScimTokenManagementState>>) -> Response {
match state.tokens.list().await {
Ok(records) => Json(json!({
"total": records.len(),
"tokens": records.iter().map(|r| json!({
"id": r.id,
"idp_name": r.idp_name,
"tenant_id": r.tenant_id,
"description": r.description,
"created_at": r.created_at,
"last_used_at": r.last_used_at,
})).collect::<Vec<_>>(),
}))
.into_response(),
Err(e) => {
tracing::error!(error = %e, "could not list SCIM provisioning tokens");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "could not list provisioning tokens" })),
)
.into_response()
},
}
}
async fn revoke_token(
State(state): State<Arc<ScimTokenManagementState>>,
Path(id): Path<String>,
) -> Response {
let Ok(uuid) = Uuid::parse_str(&id) else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "no such token" }))).into_response();
};
match state.tokens.revoke(uuid).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(fraiseql_auth::AuthError::TokenNotFound) => {
(StatusCode::NOT_FOUND, Json(json!({ "error": "no such token" }))).into_response()
},
Err(e) => {
tracing::error!(error = %e, "could not revoke a SCIM provisioning token");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "could not revoke provisioning token" })),
)
.into_response()
},
}
}