use serde_json::{json, Map, Value};
use super::directory::{normalize_user_name, parse_csv};
use super::plane::{DirUser, GroupRow, ProvisionAsk, Room, SsoPlane, UserPatch};
use crate::seam::ControlError;
pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
pub const GROUP_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
pub const CONTENT_TYPE: &str = "application/scim+json";
pub const MAX_PAGE: usize = 200;
pub const MAX_BODY: usize = 1 << 20;
#[derive(Debug, Clone)]
pub struct Call<'a> {
pub account: &'a str,
pub method: &'a str,
pub path: &'a str,
pub query: &'a str,
pub body: &'a [u8],
pub base_url: &'a str,
pub room: Room,
pub actor: &'a str,
pub now_unix_ms: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Answer {
pub status: u16,
pub body: Value,
pub location: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Effect {
pub what: &'static str,
pub principal: String,
}
fn error(status: u16, scim_type: Option<&str>, detail: impl Into<String>) -> Answer {
let mut body =
json!({ "schemas": [ERROR_SCHEMA], "status": status.to_string(), "detail": detail.into() });
if let Some(t) = scim_type {
body["scimType"] = json!(t);
}
Answer {
status,
body,
location: String::new(),
}
}
fn ok(status: u16, body: Value) -> Answer {
Answer {
status,
body,
location: String::new(),
}
}
fn from_control(err: ControlError) -> Answer {
match err {
ControlError::NotFound(m) => error(404, None, m),
ControlError::AlreadyExists(m) => error(409, Some("uniqueness"), m),
ControlError::InvalidArgument(m) => error(400, Some("invalidValue"), m),
ControlError::FailedPrecondition(m) => error(409, None, m),
ControlError::PermissionDenied(m) => error(403, None, m),
ControlError::ResourceExhausted(m) => error(429, None, m),
other => error(500, None, other.to_string()),
}
}
fn rfc3339_ms(ms: i64) -> String {
super::rfc3339(ms.div_euclid(1000))
}
pub fn user_resource(user: &DirUser, base_url: &str) -> Value {
let u = &user.user;
let mut v = json!({
"schemas": [USER_SCHEMA],
"id": u.principal_id,
"userName": u.user_name,
"active": u.active,
"meta": {
"resourceType": "User",
"created": rfc3339_ms(u.created_at_unix_ms),
"location": format!("{}/Users/{}", base_url.trim_end_matches('/'), u.principal_id),
},
});
if !u.external_id.is_empty() {
v["externalId"] = json!(u.external_id);
}
if !u.display_name.is_empty() {
v["displayName"] = json!(u.display_name);
v["name"] = json!({ "formatted": u.display_name });
}
if !u.email.is_empty() {
v["emails"] = json!([{ "value": u.email, "type": "work", "primary": true }]);
}
v
}
pub fn group_resource(group: &GroupRow, users: &[DirUser], base_url: &str) -> Value {
let members: Vec<Value> = group
.members
.iter()
.map(|m| {
let display = users.iter().find(|u| &u.user.principal_id == m).map(|u| u.user.user_name.clone()).unwrap_or_default();
json!({ "value": m, "display": display, "$ref": format!("{}/Users/{m}", base_url.trim_end_matches('/')) })
})
.collect();
let mut v = json!({
"schemas": [GROUP_SCHEMA],
"id": group.id,
"displayName": group.display_name,
"members": members,
"meta": {
"resourceType": "Group",
"created": rfc3339_ms(group.created_at_unix_ms),
"location": format!("{}/Groups/{}", base_url.trim_end_matches('/'), group.id),
},
});
if !group.external_id.is_empty() {
v["externalId"] = json!(group.external_id);
}
v
}
fn boolean(v: &Value) -> Option<bool> {
match v {
Value::Bool(b) => Some(*b),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
fn text(v: &Value) -> Option<String> {
v.as_str().map(|s| s.trim().to_owned())
}
fn email_of(v: &Value) -> Option<String> {
let list = v.as_array()?;
list.iter()
.find(|e| e.get("primary").and_then(boolean) == Some(true))
.or_else(|| list.first())
.and_then(|e| e.get("value"))
.and_then(text)
}
fn name_of(v: &Map<String, Value>) -> Option<String> {
if let Some(d) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
return Some(d);
}
let name = v.get("name")?;
if let Some(f) = name
.get("formatted")
.and_then(text)
.filter(|s| !s.is_empty())
{
return Some(f);
}
let given = name.get("givenName").and_then(text).unwrap_or_default();
let family = name.get("familyName").and_then(text).unwrap_or_default();
let joined = format!("{given} {family}").trim().to_owned();
(!joined.is_empty()).then_some(joined)
}
fn query_params(query: &str) -> (Option<String>, usize, usize) {
let mut filter = None;
let mut start = 1usize;
let mut count = 100usize;
for pair in query.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = percent_decode(v);
match k {
"filter" => filter = Some(v),
"startIndex" => start = v.parse().unwrap_or(1).max(1),
"count" => count = v.parse().unwrap_or(100).min(MAX_PAGE),
_ => {}
}
}
(filter, start, count)
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
match std::str::from_utf8(&bytes[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
Some(b) => {
out.push(b);
i += 2;
}
None => out.push(b'%'),
}
}
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn parse_filter(filter: &str) -> Result<(String, String), String> {
let f = filter.trim();
let mut parts = f.splitn(3, char::is_whitespace);
let attr = parts.next().unwrap_or_default().to_owned();
let op = parts.next().unwrap_or_default();
let rest = parts.next().unwrap_or_default().trim();
if !op.eq_ignore_ascii_case("eq") || attr.is_empty() {
return Err(format!(
"only `<attribute> eq \"<value>\"` filters are answered, not {f:?}"
));
}
let value = rest
.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.ok_or_else(|| format!("the filter's value must be quoted: {f:?}"))?
.replace("\\\"", "\"");
Ok((attr, value))
}
fn list(resources: Vec<Value>, start: usize, count: usize) -> Value {
let total = resources.len();
let page: Vec<Value> = resources.into_iter().skip(start - 1).take(count).collect();
json!({
"schemas": [LIST_SCHEMA],
"totalResults": total,
"startIndex": start,
"itemsPerPage": page.len(),
"Resources": page,
})
}
fn live(plane: &dyn SsoPlane, account: &str) -> Result<Vec<DirUser>, Answer> {
Ok(plane
.directory(account)
.map_err(from_control)?
.into_iter()
.filter(|u| !u.deleted)
.collect())
}
pub fn handle(plane: &dyn SsoPlane, call: &Call<'_>) -> (Answer, Option<Effect>) {
if call.body.len() > MAX_BODY {
return (
error(413, None, "the request body is larger than 1 MiB"),
None,
);
}
let path = call.path.trim_end_matches('/');
let mut segments = path.trim_start_matches('/').splitn(2, '/');
let collection = segments.next().unwrap_or_default();
let id = segments.next().map(percent_decode);
let method = call.method.to_ascii_uppercase();
let body: Option<Value> = if call.body.is_empty() {
None
} else {
match serde_json::from_slice(call.body) {
Ok(v) => Some(v),
Err(e) => {
return (
error(
400,
Some("invalidSyntax"),
format!("the body is not JSON: {e}"),
),
None,
)
}
}
};
match (collection, id.as_deref(), method.as_str()) {
("ServiceProviderConfig", None, "GET") => (ok(200, service_provider_config()), None),
("ResourceTypes", None, "GET") => (ok(200, resource_types(call.base_url)), None),
("Schemas", None, "GET") => (
ok(
200,
json!({ "schemas": [LIST_SCHEMA], "totalResults": 0, "Resources": [] }),
),
None,
),
("Users", None, "GET") => (users_list(plane, call), None),
("Users", None, "POST") => users_create(plane, call, body),
("Users", Some(id), "GET") => (users_get(plane, call, id), None),
("Users", Some(id), "PUT") => users_put(plane, call, id, body),
("Users", Some(id), "PATCH") => users_patch(plane, call, id, body),
("Users", Some(id), "DELETE") => users_delete(plane, call, id),
("Groups", None, "GET") => (groups_list(plane, call), None),
("Groups", None, "POST") => groups_create(plane, call, body),
("Groups", Some(id), "GET") => (groups_get(plane, call, id), None),
("Groups", Some(id), "PUT") => groups_put(plane, call, id, body),
("Groups", Some(id), "PATCH") => groups_patch(plane, call, id, body),
("Groups", Some(id), "DELETE") => groups_delete(plane, call, id),
("Users" | "Groups", _, _) => (
error(
405,
None,
format!("{method} is not answered on /{collection}"),
),
None,
),
_ => (
error(404, None, format!("there is no SCIM resource at {path:?}")),
None,
),
}
}
fn service_provider_config() -> Value {
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": MAX_PAGE },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": false },
"authenticationSchemes": [{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "A bearer the account admin creates in gunnar (Administration → Identity provider).",
"primary": true,
}],
})
}
fn resource_types(base_url: &str) -> Value {
let base = base_url.trim_end_matches('/');
json!({
"schemas": [LIST_SCHEMA],
"totalResults": 2,
"Resources": [
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], "id": "User", "name": "User", "endpoint": "/Users", "schema": USER_SCHEMA, "meta": { "location": format!("{base}/ResourceTypes/User"), "resourceType": "ResourceType" } },
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], "id": "Group", "name": "Group", "endpoint": "/Groups", "schema": GROUP_SCHEMA, "meta": { "location": format!("{base}/ResourceTypes/Group"), "resourceType": "ResourceType" } },
],
})
}
fn users_list(plane: &dyn SsoPlane, call: &Call<'_>) -> Answer {
let users = match live(plane, call.account) {
Ok(u) => u,
Err(a) => return a,
};
let (filter, start, count) = query_params(call.query);
let users: Vec<&DirUser> = match filter.as_deref().filter(|f| !f.trim().is_empty()) {
None => users.iter().collect(),
Some(f) => {
let (attr, value) = match parse_filter(f) {
Ok(p) => p,
Err(why) => return error(400, Some("invalidFilter"), why),
};
let value_l = value.to_lowercase();
users
.iter()
.filter(|u| match attr.to_ascii_lowercase().as_str() {
"username" => u.user.user_name == value_l,
"externalid" => u.user.external_id == value,
"id" => u.user.principal_id == value,
"emails.value" | "emails" => u.user.email.eq_ignore_ascii_case(&value),
_ => false,
})
.collect()
}
};
if let Some(f) = filter.as_deref() {
if let Ok((attr, _)) = parse_filter(f) {
if !matches!(
attr.to_ascii_lowercase().as_str(),
"username" | "externalid" | "id" | "emails.value" | "emails"
) {
return error(
400,
Some("invalidFilter"),
format!("users are not filtered by {attr:?}"),
);
}
}
}
ok(
200,
list(
users
.into_iter()
.map(|u| user_resource(u, call.base_url))
.collect(),
start,
count,
),
)
}
fn find_user(plane: &dyn SsoPlane, account: &str, id: &str) -> Result<DirUser, Answer> {
live(plane, account)?
.into_iter()
.find(|u| u.user.principal_id == id)
.ok_or_else(|| error(404, None, format!("no user {id} in this directory")))
}
fn users_get(plane: &dyn SsoPlane, call: &Call<'_>, id: &str) -> Answer {
match find_user(plane, call.account, id) {
Ok(u) => ok(200, user_resource(&u, call.base_url)),
Err(a) => a,
}
}
fn users_create(
plane: &dyn SsoPlane,
call: &Call<'_>,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a User is a JSON object"),
None,
);
};
let Some(user_name) = v
.get("userName")
.and_then(text)
.and_then(|s| normalize_user_name(&s))
else {
return (
error(400, Some("invalidValue"), "`userName` is required"),
None,
);
};
let ask = ProvisionAsk {
account: call.account.to_owned(),
user_name,
display_name: name_of(&v).unwrap_or_default(),
email: v.get("emails").and_then(email_of).unwrap_or_default(),
external_id: v.get("externalId").and_then(text).unwrap_or_default(),
active: v.get("active").and_then(boolean).unwrap_or(true),
source: "scim",
room: call.room.clone(),
actor: call.actor.to_owned(),
now_unix_ms: call.now_unix_ms,
};
match plane.provision(ask) {
Ok(user) => {
let mut answer = ok(201, user_resource(&user, call.base_url));
answer.location = format!(
"{}/Users/{}",
call.base_url.trim_end_matches('/'),
user.user.principal_id
);
let effect = Effect {
what: "created",
principal: user.user.principal_id.clone(),
};
(answer, Some(effect))
}
Err(err) => (from_control(err), None),
}
}
fn apply(
plane: &dyn SsoPlane,
call: &Call<'_>,
id: &str,
patch: UserPatch,
) -> (Answer, Option<Effect>) {
if let Err(a) = find_user(plane, call.account, id) {
return (a, None);
}
let deactivating = patch.active;
match plane.update_user(call.account, id, patch, call.actor) {
Ok((user, changed)) => {
let what = match (changed, deactivating) {
(true, Some(false)) => "deactivated",
(true, Some(true)) => "reactivated",
_ => "updated",
};
(
ok(200, user_resource(&user, call.base_url)),
Some(Effect {
what,
principal: id.to_owned(),
}),
)
}
Err(err) => (from_control(err), None),
}
}
fn users_put(
plane: &dyn SsoPlane,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a User is a JSON object"),
None,
);
};
let user_name = match v.get("userName").and_then(text) {
Some(s) => match normalize_user_name(&s) {
Some(n) => Some(n),
None => {
return (
error(400, Some("invalidValue"), "`userName` is empty"),
None,
)
}
},
None => None,
};
let patch = UserPatch {
user_name,
display_name: Some(name_of(&v).unwrap_or_default()),
email: Some(v.get("emails").and_then(email_of).unwrap_or_default()),
external_id: v.get("externalId").and_then(text),
active: Some(v.get("active").and_then(boolean).unwrap_or(true)),
};
apply(plane, call, id, patch)
}
fn user_op(
patch: &mut UserPatch,
op: &str,
path: Option<&str>,
value: Option<&Value>,
) -> Result<(), String> {
let remove = op == "remove";
let path = path.map(|p| p.trim().to_ascii_lowercase());
match path.as_deref() {
None => {
let Some(Value::Object(map)) = value else {
return Err("an operation without a `path` carries an object `value`".to_owned());
};
for (k, v) in map {
user_op(patch, op, Some(k), Some(v))?;
}
}
Some("active") => {
patch.active = Some(if remove {
false
} else {
value.and_then(boolean).ok_or("`active` is true or false")?
});
}
Some("username") => {
let name = value
.and_then(text)
.and_then(|s| normalize_user_name(&s))
.ok_or("`userName` is required")?;
if remove {
return Err("`userName` cannot be removed".to_owned());
}
patch.user_name = Some(name);
}
Some("displayname") | Some("name.formatted") => {
patch.display_name = Some(if remove {
String::new()
} else {
value.and_then(text).unwrap_or_default()
});
}
Some("name") => {
if let Some(Value::Object(m)) = value {
let mut wrapper = Map::new();
wrapper.insert("name".to_owned(), Value::Object(m.clone()));
patch.display_name = Some(name_of(&wrapper).unwrap_or_default());
} else if remove {
patch.display_name = Some(String::new());
}
}
Some("name.givenname") | Some("name.familyname") => {
if let Some(v) = value.and_then(text) {
let old = patch.display_name.clone().unwrap_or_default();
patch.display_name = Some(if old.is_empty() {
v
} else {
format!("{old} {v}")
});
}
}
Some("externalid") => {
patch.external_id = Some(if remove {
String::new()
} else {
value.and_then(text).unwrap_or_default()
});
}
Some(p) if p == "emails" || p.starts_with("emails[") || p.starts_with("emails.") => {
patch.email = Some(if remove {
String::new()
} else {
match value {
Some(v @ Value::Array(_)) => email_of(v).unwrap_or_default(),
Some(v) => text(v).unwrap_or_default(),
None => String::new(),
}
});
}
Some(_) => {}
}
Ok(())
}
type Operation = (String, Option<String>, Option<Value>);
fn operations(body: Option<Value>) -> Result<Vec<Operation>, Answer> {
let Some(Value::Object(v)) = body else {
return Err(error(
400,
Some("invalidSyntax"),
"a PatchOp is a JSON object",
));
};
let ops = v
.get("Operations")
.or_else(|| v.get("operations"))
.and_then(Value::as_array)
.ok_or_else(|| error(400, Some("invalidSyntax"), "a PatchOp carries `Operations`"))?;
let mut out = Vec::new();
for op in ops {
let name = op
.get("op")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(name.as_str(), "add" | "replace" | "remove") {
return Err(error(
400,
Some("invalidSyntax"),
format!("unknown PatchOp op {name:?}"),
));
}
out.push((
name,
op.get("path").and_then(Value::as_str).map(str::to_owned),
op.get("value").cloned(),
));
}
Ok(out)
}
fn users_patch(
plane: &dyn SsoPlane,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let ops = match operations(body) {
Ok(ops) => ops,
Err(a) => return (a, None),
};
let mut patch = UserPatch::default();
for (op, path, value) in &ops {
if let Err(why) = user_op(&mut patch, op, path.as_deref(), value.as_ref()) {
return (error(400, Some("invalidValue"), why), None);
}
}
apply(plane, call, id, patch)
}
fn users_delete(plane: &dyn SsoPlane, call: &Call<'_>, id: &str) -> (Answer, Option<Effect>) {
if let Err(a) = find_user(plane, call.account, id) {
return (a, None);
}
match plane.delete_user(call.account, id, call.actor) {
Ok(_) => (
ok(204, Value::Null),
Some(Effect {
what: "deleted",
principal: id.to_owned(),
}),
),
Err(err) => (from_control(err), None),
}
}
fn groups_list(plane: &dyn SsoPlane, call: &Call<'_>) -> Answer {
let groups = match plane.groups(call.account) {
Ok(g) => g,
Err(e) => return from_control(e),
};
let users = match live(plane, call.account) {
Ok(u) => u,
Err(a) => return a,
};
let (filter, start, count) = query_params(call.query);
let groups: Vec<&GroupRow> = match filter.as_deref().filter(|f| !f.trim().is_empty()) {
None => groups.iter().collect(),
Some(f) => {
let (attr, value) = match parse_filter(f) {
Ok(p) => p,
Err(why) => return error(400, Some("invalidFilter"), why),
};
match attr.to_ascii_lowercase().as_str() {
"displayname" => groups
.iter()
.filter(|g| g.display_name.eq_ignore_ascii_case(&value))
.collect(),
"externalid" => groups.iter().filter(|g| g.external_id == value).collect(),
"id" => groups.iter().filter(|g| g.id == value).collect(),
other => {
return error(
400,
Some("invalidFilter"),
format!("groups are not filtered by {other:?}"),
)
}
}
}
};
ok(
200,
list(
groups
.into_iter()
.map(|g| group_resource(g, &users, call.base_url))
.collect(),
start,
count,
),
)
}
fn find_group(plane: &dyn SsoPlane, account: &str, id: &str) -> Result<GroupRow, Answer> {
plane
.groups(account)
.map_err(from_control)?
.into_iter()
.find(|g| g.id == id)
.ok_or_else(|| error(404, None, format!("no group {id} in this directory")))
}
fn groups_get(plane: &dyn SsoPlane, call: &Call<'_>, id: &str) -> Answer {
let users = match live(plane, call.account) {
Ok(u) => u,
Err(a) => return a,
};
match find_group(plane, call.account, id) {
Ok(g) => ok(200, group_resource(&g, &users, call.base_url)),
Err(a) => a,
}
}
fn member_ids(v: Option<&Value>) -> Vec<String> {
match v {
Some(Value::Array(list)) => list
.iter()
.filter_map(|m| m.get("value").and_then(text))
.filter(|s| !s.is_empty())
.collect(),
Some(Value::Object(_)) => v
.and_then(|m| m.get("value"))
.and_then(text)
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn store_group(
plane: &dyn SsoPlane,
call: &Call<'_>,
group: GroupRow,
status: u16,
what: &'static str,
) -> (Answer, Option<Effect>) {
let users = match live(plane, call.account) {
Ok(u) => u,
Err(a) => return (a, None),
};
match plane.put_group(group) {
Ok(g) => {
let mut answer = ok(status, group_resource(&g, &users, call.base_url));
if status == 201 {
answer.location =
format!("{}/Groups/{}", call.base_url.trim_end_matches('/'), g.id);
}
(
answer,
Some(Effect {
what,
principal: format!("group:{}", g.id),
}),
)
}
Err(e) => (from_control(e), None),
}
}
fn groups_create(
plane: &dyn SsoPlane,
call: &Call<'_>,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a Group is a JSON object"),
None,
);
};
let Some(display_name) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty() && s.len() <= 256)
else {
return (
error(400, Some("invalidValue"), "`displayName` is required"),
None,
);
};
let group = GroupRow {
id: crate::webauthn::hex(&crate::webauthn::random_bytes(12)),
account: call.account.to_owned(),
display_name,
external_id: v.get("externalId").and_then(text).unwrap_or_default(),
members: member_ids(v.get("members")),
created_at_unix_ms: call.now_unix_ms,
};
store_group(plane, call, group, 201, "group created")
}
fn groups_put(
plane: &dyn SsoPlane,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let mut group = match find_group(plane, call.account, id) {
Ok(g) => g,
Err(a) => return (a, None),
};
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a Group is a JSON object"),
None,
);
};
if let Some(name) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
group.display_name = name;
}
if let Some(ext) = v.get("externalId").and_then(text) {
group.external_id = ext;
}
group.members = member_ids(v.get("members"));
store_group(plane, call, group, 200, "group replaced")
}
fn member_in_path(path: &str) -> Option<String> {
let inner = path.trim().strip_prefix("members[")?.strip_suffix(']')?;
parse_filter(inner)
.ok()
.filter(|(a, _)| a.eq_ignore_ascii_case("value"))
.map(|(_, v)| v)
}
fn groups_patch(
plane: &dyn SsoPlane,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let mut group = match find_group(plane, call.account, id) {
Ok(g) => g,
Err(a) => return (a, None),
};
let ops = match operations(body) {
Ok(ops) => ops,
Err(a) => return (a, None),
};
for (op, path, value) in ops {
let path_l = path.as_deref().map(|p| p.trim().to_ascii_lowercase());
match (op.as_str(), path_l.as_deref()) {
("add", Some("members")) => {
for m in member_ids(value.as_ref()) {
if !group.members.contains(&m) {
group.members.push(m);
}
}
}
("replace", Some("members")) => group.members = member_ids(value.as_ref()),
("remove", Some("members")) => {
let gone = member_ids(value.as_ref());
if gone.is_empty() {
group.members.clear();
} else {
group.members.retain(|m| !gone.contains(m));
}
}
("remove", Some(p)) if p.starts_with("members[") => {
if let Some(m) = path.as_deref().and_then(member_in_path) {
group.members.retain(|x| *x != m);
}
}
("add" | "replace", Some("displayname")) => {
if let Some(name) = value.as_ref().and_then(text).filter(|s| !s.is_empty()) {
group.display_name = name;
}
}
("add" | "replace", Some("externalid")) => {
group.external_id = value.as_ref().and_then(text).unwrap_or_default()
}
("add" | "replace", None) => {
if let Some(Value::Object(map)) = &value {
if let Some(name) = map
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
group.display_name = name;
}
if let Some(ext) = map.get("externalId").and_then(text) {
group.external_id = ext;
}
if map.contains_key("members") {
let add = member_ids(map.get("members"));
if op == "replace" {
group.members = add;
} else {
for m in add {
if !group.members.contains(&m) {
group.members.push(m);
}
}
}
}
}
}
_ => {}
}
}
store_group(plane, call, group, 200, "group changed")
}
fn groups_delete(plane: &dyn SsoPlane, call: &Call<'_>, id: &str) -> (Answer, Option<Effect>) {
match plane.remove_group(call.account, id) {
Ok(true) => (
ok(204, Value::Null),
Some(Effect {
what: "group deleted",
principal: format!("group:{id}"),
}),
),
Ok(false) => (
error(404, None, format!("no group {id} in this directory")),
None,
),
Err(e) => (from_control(e), None),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Imported {
pub created: u32,
pub updated: u32,
pub deactivated: u32,
pub reactivated: u32,
pub unchanged: u32,
pub errors: Vec<String>,
}
pub fn import_csv(
plane: &dyn SsoPlane,
account: &str,
csv: &str,
dry_run: bool,
room: &Room,
actor: &str,
now_unix_ms: i64,
) -> Result<Imported, String> {
let (rows, mut errors) = parse_csv(csv)?;
let users = plane.directory(account).map_err(|e| e.to_string())?;
let mut out = Imported::default();
for row in rows {
let known = users
.iter()
.find(|u| !u.deleted && u.user.user_name == row.user_name);
match known {
None => {
if dry_run {
out.created += 1;
continue;
}
match plane.provision(ProvisionAsk {
account: account.to_owned(),
user_name: row.user_name.clone(),
display_name: row.display_name.clone(),
email: row.email.clone(),
external_id: String::new(),
active: row.active,
source: "csv",
room: room.clone(),
actor: actor.to_owned(),
now_unix_ms,
}) {
Ok(_) => out.created += 1,
Err(e) => errors.push(format!("line {}: {}: {e}", row.line, row.user_name)),
}
}
Some(user) => {
let u = &user.user;
let names_differ = (!row.display_name.is_empty()
&& row.display_name != u.display_name)
|| (!row.email.is_empty() && row.email != u.email);
let active_differs = row.active != u.active;
if !names_differ && !active_differs {
out.unchanged += 1;
continue;
}
if dry_run {
match (active_differs, row.active) {
(true, false) => out.deactivated += 1,
(true, true) => out.reactivated += 1,
_ => out.updated += 1,
}
continue;
}
let patch = UserPatch {
display_name: (!row.display_name.is_empty()).then(|| row.display_name.clone()),
email: (!row.email.is_empty()).then(|| row.email.clone()),
active: Some(row.active),
..UserPatch::default()
};
match plane.update_user(account, &u.principal_id, patch, actor) {
Ok((_, true)) if !row.active => out.deactivated += 1,
Ok((_, true)) => out.reactivated += 1,
Ok((_, false)) => out.updated += 1,
Err(e) => errors.push(format!("line {}: {}: {e}", row.line, row.user_name)),
}
}
}
}
out.errors = errors;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_are_eq_only_and_quoted() {
assert_eq!(
parse_filter("userName eq \"bob@acme.example\"").unwrap(),
("userName".into(), "bob@acme.example".into())
);
assert_eq!(parse_filter("externalId EQ \"a\\\"b\"").unwrap().1, "a\"b");
assert!(parse_filter("userName co \"bob\"").is_err());
assert!(parse_filter("userName eq bob").is_err());
assert_eq!(
member_in_path("members[value eq \"acme.bob\"]").as_deref(),
Some("acme.bob")
);
}
#[test]
fn query_and_percent_decoding() {
let (f, start, count) =
query_params("filter=userName%20eq%20%22bob%40acme.example%22&startIndex=2&count=1000");
assert_eq!(f.as_deref(), Some("userName eq \"bob@acme.example\""));
assert_eq!((start, count), (2, MAX_PAGE));
assert_eq!(percent_decode("a%2"), "a%2");
}
#[test]
fn entra_and_okta_patch_shapes() {
let mut p = UserPatch::default();
user_op(&mut p, "replace", Some("active"), Some(&json!("False"))).unwrap();
assert_eq!(p.active, Some(false));
let mut p = UserPatch::default();
user_op(
&mut p,
"replace",
None,
Some(&json!({"active": true, "displayName": "Bob B"})),
)
.unwrap();
assert_eq!(
(p.active, p.display_name.as_deref()),
(Some(true), Some("Bob B"))
);
let mut p = UserPatch::default();
user_op(
&mut p,
"replace",
Some("emails[type eq \"work\"].value"),
Some(&json!("b@acme.example")),
)
.unwrap();
assert_eq!(p.email.as_deref(), Some("b@acme.example"));
let mut p = UserPatch::default();
assert!(user_op(&mut p, "replace", Some("active"), Some(&json!("maybe"))).is_err());
user_op(
&mut p,
"add",
Some("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department"),
Some(&json!("R&D")),
)
.unwrap();
}
}