use alibi_core::utils::json::JsValue;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
pub(super) struct CollectionQuery {
pub start_index: i64,
pub count: i64,
pub filters: Vec<(String, String)>,
}
pub(super) struct QueryError {
pub detail: String,
pub kind: &'static str,
}
fn error(detail: impl Into<String>, kind: &'static str) -> QueryError {
QueryError {
detail: detail.into(),
kind,
}
}
fn integer(value: Option<&JsValue>, name: &str, default: i64) -> Result<i64, QueryError> {
let Some(value) = value else {
return Ok(default);
};
let parsed = match value {
JsValue::Number(number) => Some(*number),
JsValue::String(value) => {
let value = alibi_core::utils::javascript::trim(value);
let digits = value.strip_prefix('-').unwrap_or(value);
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
value.parse::<f64>().ok()
} else {
None
}
}
_ => None,
};
let number = parsed
.filter(|value| {
value.is_finite() && value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0
})
.ok_or_else(|| error(format!("{name} must be an integer"), "invalidValue"))?;
format!("{number:.0}")
.parse()
.map_err(|_| error(format!("{name} must be an integer"), "invalidValue"))
}
struct Word {
start: usize,
end: usize,
value: String,
}
fn words(filter: &str) -> Result<Vec<Word>, QueryError> {
let bytes = filter.as_bytes();
let mut position = 0;
let mut quoted = false;
let mut escaped = false;
let mut brackets = 0;
let mut words = Vec::new();
while let Some(&byte) = bytes.get(position) {
if quoted {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
quoted = false;
}
position += 1;
continue;
}
if byte == b'"' {
quoted = true;
position += 1;
continue;
}
if byte == b'[' {
brackets += 1;
position += 1;
continue;
}
if byte == b']' {
if brackets == 0 {
return Err(error(
"filter contains malformed quotes or brackets",
"invalidFilter",
));
}
brackets -= 1;
position += 1;
continue;
}
if brackets == 0 && byte.is_ascii_alphabetic() {
let start = position;
while bytes.get(position).is_some_and(u8::is_ascii_alphabetic) {
position += 1;
}
words.push(Word {
start,
end: position,
value: filter.get(start..position).unwrap_or("").into(),
});
} else {
position += 1;
}
}
if quoted || brackets != 0 {
return Err(error(
"filter contains malformed quotes or brackets",
"invalidFilter",
));
}
Ok(words)
}
fn surrounded(filter: &str, word: &Word) -> bool {
filter
.get(..word.start)
.and_then(|prefix| prefix.chars().last())
.is_some_and(alibi_core::utils::javascript::is_whitespace)
&& filter
.get(word.end..)
.and_then(|suffix| suffix.chars().next())
.is_some_and(alibi_core::utils::javascript::is_whitespace)
}
fn conjunction(filter: &str) -> Result<Vec<&str>, QueryError> {
let mut output = Vec::new();
let mut start = 0;
for word in words(filter)? {
if word.value.eq_ignore_ascii_case("and") && surrounded(filter, &word) {
let expression =
alibi_core::utils::javascript::trim(filter.get(start..word.start).unwrap_or(""));
if expression.is_empty() {
return Err(error(
"filter contains an invalid conjunction",
"invalidFilter",
));
}
output.push(expression);
start = word.end;
}
}
let final_expression = alibi_core::utils::javascript::trim(filter.get(start..).unwrap_or(""));
if final_expression.is_empty() {
return Err(error(
"filter contains an invalid conjunction",
"invalidFilter",
));
}
if output.len() >= 10 {
return Err(error(
"filter supports at most 10 equality expressions",
"invalidFilter",
));
}
output.push(final_expression);
Ok(output)
}
fn equality(
expression: &str,
connection: &str,
kind: &str,
) -> Result<(String, String), QueryError> {
let mut operation = None;
for word in words(expression)? {
if !surrounded(expression, &word) {
continue;
}
let attribute =
alibi_core::utils::javascript::trim(expression.get(..word.start).unwrap_or(""));
let value = alibi_core::utils::javascript::trim(expression.get(word.end..).unwrap_or(""));
if !attribute.is_empty() && !value.is_empty() {
operation = Some((attribute, word.value, value));
break;
}
}
let Some((attribute, operator, value)) = operation else {
return Err(error(
"filter must use the form attribute eq \"value\"",
"invalidFilter",
));
};
if !operator.eq_ignore_ascii_case("eq") {
return Err(error(
format!("filter operator {operator} is not supported"),
"invalidFilter",
));
}
let lower = attribute.to_lowercase();
let normalized = lower
.strip_prefix(&format!(
"urn:ietf:params:scim:schemas:core:2.0:{}:",
kind.to_lowercase()
))
.unwrap_or(&lower);
let whitespace_normalized: String = normalized
.chars()
.map(|character| {
if alibi_core::utils::javascript::is_whitespace(character) {
' '
} else {
character
}
})
.collect();
let work_email = kind == "User"
&& regex::Regex::new(r#"(?i)^emails\[ *type +eq +"work" *\]\.value$"#)
.map_err(|error_value| error(error_value.to_string(), "invalidFilter"))?
.is_match(&whitespace_normalized);
let field = match normalized {
_ if work_email => "workEmailValueIndex",
"id" => "id",
"displayname" if kind == "Group" => "displayNameKey",
"username" if kind == "User" => "userNameKey",
"emails.value" if kind == "User" => "emailValueIndex",
"externalid" => "externalIdKey",
_ => {
return Err(error(
format!("filter attribute {attribute} is not supported for {kind}"),
"invalidFilter",
));
}
};
let parsed: serde_json::Value = serde_json::from_str(value).map_err(|_| {
error(
"filter equality value must be a valid quoted JSON string",
"invalidFilter",
)
})?;
let value = parsed.as_str().ok_or_else(|| {
error(
"filter equality value must be a quoted string",
"invalidFilter",
)
})?;
let prepared = match field {
"emailValueIndex" | "workEmailValueIndex" => format!(
"|{}|",
URL_SAFE_NO_PAD.encode(Sha256::digest(
serde_json::to_vec(&[
"scim-email-value",
&alibi_core::utils::javascript::trim(value).to_lowercase()
])
.map_err(|error_value| error(error_value.to_string(), "invalidFilter"))?
))
),
"userNameKey" => URL_SAFE_NO_PAD.encode(Sha256::digest(
serde_json::to_vec(&["scim-user-name", connection, &value.to_lowercase()])
.map_err(|error_value| error(error_value.to_string(), "invalidFilter"))?,
)),
"externalIdKey" if kind == "User" && !value.is_empty() => {
URL_SAFE_NO_PAD.encode(Sha256::digest(
serde_json::to_vec(&["scim-user-external-id", connection, value])
.map_err(|error_value| error(error_value.to_string(), "invalidFilter"))?,
))
}
"displayNameKey" => URL_SAFE_NO_PAD.encode(Sha256::digest(
serde_json::to_vec(&["scim-group-display-name", connection, &value.to_lowercase()])
.map_err(|_| {
error(
"filter equality value must be a quoted string",
"invalidFilter",
)
})?,
)),
"externalIdKey" if !value.is_empty() => URL_SAFE_NO_PAD.encode(Sha256::digest(
serde_json::to_vec(&["scim-group-external-id", connection, value]).map_err(|_| {
error(
"filter equality value must be a quoted string",
"invalidFilter",
)
})?,
)),
"externalIdKey" => String::new(),
_ => value.into(),
};
Ok((field.into(), prepared))
}
pub(super) fn parse_group(
query: Option<&JsValue>,
connection: &str,
) -> Result<CollectionQuery, QueryError> {
parse(query, connection, "Group")
}
pub(super) fn parse_user(
query: Option<&JsValue>,
connection: &str,
) -> Result<CollectionQuery, QueryError> {
parse(query, connection, "User")
}
fn parse(
query: Option<&JsValue>,
connection: &str,
kind: &str,
) -> Result<CollectionQuery, QueryError> {
let start_index = integer(
query.and_then(|query| query.get("startIndex")),
"startIndex",
1,
)?
.max(1);
let count = integer(query.and_then(|query| query.get("count")), "count", 100)?.clamp(0, 100);
let mut filters = Vec::new();
if let Some(filter) = query
.and_then(|query| query.get("filter"))
.and_then(JsValue::as_str)
.filter(|filter| !alibi_core::utils::javascript::trim(filter).is_empty())
{
for expression in conjunction(filter)? {
filters.push(equality(expression, connection, kind)?);
}
}
Ok(CollectionQuery {
start_index,
count,
filters,
})
}