use alibi_core::{
AuthError, AuthResult,
store::{ScimGroupPatchKind as Kind, ScimGroupPatchOperation as Operation},
utils::{javascript::trim, json::JsValue},
};
use indexmap::IndexMap;
const MAX_DEPTH: usize = 32;
pub(super) enum Target {
Merge { path: String, separator: char },
Entries,
ReadOnly,
NotNullable,
Value,
}
impl Target {
fn sub_path(&self, attribute: &str) -> String {
match self {
Self::Merge { path, separator } => format!("{path}{separator}{attribute}"),
_ => attribute.to_owned(),
}
}
}
fn strip(value: &JsValue, depth: usize) -> JsValue {
if depth > MAX_DEPTH {
return value.clone();
}
match value {
JsValue::Array(entries) => JsValue::Array(
entries
.iter()
.map(|entry| strip(entry, depth + 1))
.collect(),
),
JsValue::Object(entries) => {
let mut output = IndexMap::new();
for (key, entry) in entries {
if matches!(entry, JsValue::Null) {
continue;
}
let stripped = strip(entry, depth + 1);
if matches!(&stripped, JsValue::Object(fields) if fields.is_empty())
&& stripped != *entry
{
continue;
}
_ = output.insert(key.clone(), stripped);
}
JsValue::Object(output)
}
_ => value.clone(),
}
}
pub(super) fn strip_resource(resource: &JsValue) -> JsValue {
let JsValue::Object(fields) = resource else {
return resource.clone();
};
let mut stripped = strip(resource, 0);
if matches!(fields.get("active"), Some(JsValue::Null))
&& let JsValue::Object(output) = &mut stripped
{
_ = output.insert("active".into(), JsValue::Null);
}
stripped
}
fn single_record(value: &JsValue) -> Option<&IndexMap<String, JsValue>> {
match value {
JsValue::Object(fields) => Some(fields),
JsValue::Array(entries) if entries.len() == 1 => match entries.first() {
Some(JsValue::Object(fields)) => Some(fields),
_ => None,
},
_ => None,
}
}
fn removal(path: String) -> Operation {
Operation {
op: Kind::Remove,
path: Some(path),
value: None,
}
}
fn null_operations(path: String, target: &Target) -> AuthResult<Vec<Operation>> {
match target {
Target::ReadOnly => Ok(Vec::new()),
Target::NotNullable => Err(AuthError::Api {
status: 400,
code: Some("invalidValue".into()),
message: format!("{path} cannot be null"),
}),
_ => Ok(vec![removal(path)]),
}
}
fn split(
value: &IndexMap<String, JsValue>,
parent: Option<&Target>,
resolve: &impl Fn(&str) -> Target,
removals: &mut Vec<Operation>,
) -> AuthResult<IndexMap<String, JsValue>> {
let mut kept = IndexMap::new();
for (attribute, entry) in value {
if !matches!(
entry,
JsValue::Null | JsValue::Object(_) | JsValue::Array(_)
) {
_ = kept.insert(attribute.clone(), entry.clone());
continue;
}
let path = parent.map_or_else(|| attribute.clone(), |parent| parent.sub_path(attribute));
let target = resolve(&path);
if matches!(entry, JsValue::Null) {
removals.extend(null_operations(path, &target)?);
continue;
}
if let Target::Merge { .. } = target
&& let Some(record) = single_record(entry)
{
let nested = split(record, Some(&target), resolve, removals)?;
if !nested.is_empty() || record.is_empty() {
let nested = JsValue::Object(nested);
_ = kept.insert(
attribute.clone(),
if matches!(entry, JsValue::Array(_)) {
JsValue::Array(vec![nested])
} else {
nested
},
);
}
continue;
}
_ = kept.insert(
attribute.clone(),
if matches!(target, Target::Entries) {
strip(entry, 0)
} else {
entry.clone()
},
);
}
Ok(kept)
}
fn expand_operation(
operation: &Operation,
resolve: &impl Fn(&str) -> Target,
) -> AuthResult<Vec<Operation>> {
if operation.op == Kind::Remove {
return Ok(vec![operation.clone()]);
}
let path = operation
.path
.as_deref()
.map(trim)
.filter(|path| !path.is_empty());
let target = path.map(resolve);
if let (Some(path), Some(target)) = (path, &target)
&& matches!(operation.value, Some(JsValue::Null))
{
return null_operations(path.to_owned(), target);
}
if matches!(target, Some(Target::Entries)) {
let mut stripped = operation.clone();
stripped.value = operation.value.as_ref().map(|value| strip(value, 0));
return Ok(vec![stripped]);
}
let merge = matches!(target, Some(Target::Merge { .. }));
if path.is_some() && !merge {
return Ok(vec![operation.clone()]);
}
let record = match operation.value.as_ref() {
Some(value) if merge => single_record(value),
Some(JsValue::Object(fields)) => Some(fields),
_ => None,
};
let Some(record) = record else {
return Ok(vec![operation.clone()]);
};
let mut removals = Vec::new();
let value = split(record, target.as_ref(), resolve, &mut removals)?;
let mut operations = Vec::new();
if !value.is_empty() || record.is_empty() {
let mut kept = operation.clone();
kept.value = Some(if matches!(operation.value, Some(JsValue::Array(_))) {
JsValue::Array(vec![JsValue::Object(value)])
} else {
JsValue::Object(value)
});
operations.push(kept);
}
operations.extend(removals);
Ok(operations)
}
pub(super) fn expand(
operations: &[Operation],
resolve: impl Fn(&str) -> Target,
) -> AuthResult<Vec<Operation>> {
let mut expanded = Vec::with_capacity(operations.len());
for operation in operations {
expanded.extend(expand_operation(operation, &resolve)?);
}
Ok(expanded)
}
pub(super) fn group_target(path: &str) -> Target {
match super::attribute_names::strip_group_prefix(path)
.to_lowercase()
.as_str()
{
"members" => Target::Entries,
"schemas" | "meta" => Target::ReadOnly,
_ => Target::Value,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> JsValue {
alibi_core::utils::json::from_slice(raw.as_bytes()).unwrap()
}
#[test]
fn strips_null_attributes_but_keeps_root_active() {
assert_eq!(
strip_resource(&parse(
r#"{"title":null,"name":{"middleName":null},"emails":[{"value":"a","type":null},null],"active":null}"#
)),
parse(r#"{"emails":[{"value":"a"},null],"active":null}"#)
);
}
#[test]
fn expands_pathless_null_values_into_trailing_removals() {
let operations = expand(
&[Operation {
op: Kind::Replace,
path: None,
value: Some(parse(
r#"{"externalId":null,"schemas":null,"displayName":"Platform","members":[{"value":"u","display":null}]}"#,
)),
}],
group_target,
)
.unwrap();
let kept = Some(parse(
r#"{"displayName":"Platform","members":[{"value":"u"}]}"#,
));
assert!(matches!(
operations.as_slice(),
[operation, removal]
if operation.value == kept
&& removal.op == Kind::Remove
&& removal.path.as_deref() == Some("externalId")
));
}
}