Skip to main content

alibi_core/store/
scim_group_patch.rs

1//! Executable Group PATCH plans shared by the durable adapters.
2use super::{ReplaceScimGroup, ScimGroupRecord};
3use crate::{
4    AuthError, AuthResult,
5    utils::{javascript::trim, json::JsValue},
6};
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum ScimGroupPatchKind {
9    Add,
10    Remove,
11    Replace,
12}
13#[derive(Clone)]
14pub struct ScimGroupPatchOperation {
15    pub op: ScimGroupPatchKind,
16    pub path: Option<String>,
17    pub value: Option<JsValue>,
18}
19#[derive(Clone)]
20pub enum ScimGroupPatch {
21    Incremental {
22        desired: Vec<(String, bool)>,
23        validate: Vec<String>,
24    },
25    Full(Vec<ScimGroupPatchOperation>),
26}
27fn error(detail: impl Into<String>, kind: &str) -> AuthError {
28    AuthError::Api {
29        status: 400,
30        code: Some(kind.into()),
31        message: detail.into(),
32    }
33}
34fn normalized_path(path: &str) -> &str {
35    let path = trim(path);
36    let prefix = "urn:ietf:params:scim:schemas:core:2.0:Group:";
37    path.get(..prefix.len())
38        .filter(|value| value.eq_ignore_ascii_case(prefix))
39        .and_then(|_| path.get(prefix.len()..))
40        .unwrap_or(path)
41}
42fn member_path(path: &str) -> Option<String> {
43    let space = r"[\u{0009}-\u{000D}\u{0020}\u{00A0}\u{1680}\u{2000}-\u{200A}\u{2028}\u{2029}\u{202F}\u{205F}\u{3000}\u{FEFF}]";
44    let pattern = format!(
45        r#"^(?i-u:members){space}*\[{space}*(?i-u:value){space}+(?i-u:eq){space}+("(?:\\.|[^"\\])*"){space}*\]$"#
46    );
47    let regex = regex::Regex::new(&pattern).ok()?;
48    let capture = regex.captures(normalized_path(path))?.get(1)?;
49    let value = serde_json::from_str::<String>(capture.as_str()).ok()?;
50    (!value.is_empty()).then_some(value)
51}
52fn members(value: Option<&JsValue>) -> AuthResult<Vec<String>> {
53    let Some(JsValue::Array(entries)) = value else {
54        return Err(error("members must be an array", "invalidValue"));
55    };
56    cardinality(entries.len())?;
57    let mut ids = Vec::new();
58    for entry in entries {
59        let Some(JsValue::Object(entry)) = Some(entry) else {
60            return Err(error(
61                "Group members must reference a SCIM User",
62                "invalidValue",
63            ));
64        };
65        let Some(id) = entry
66            .get("value")
67            .and_then(JsValue::as_str)
68            .filter(|value| !trim(value).is_empty())
69        else {
70            return Err(error(
71                "Group members must reference a SCIM User",
72                "invalidValue",
73            ));
74        };
75        if entry
76            .get("type")
77            .and_then(JsValue::as_str)
78            .is_some_and(|value| !value.eq_ignore_ascii_case("user"))
79        {
80            return Err(error(
81                "Group members must reference a SCIM User",
82                "invalidValue",
83            ));
84        }
85        if !ids.iter().any(|existing| existing == id) {
86            ids.push(id.into());
87        }
88    }
89    Ok(ids)
90}
91/// Enforce the reference's direct-member cap after a planned delta.
92/// # Errors
93/// Returns a SCIM invalidValue error when the cap would be exceeded.
94pub fn check_scim_group_cardinality(count: usize) -> AuthResult<()> {
95    cardinality(count)
96}
97fn cardinality(count: usize) -> AuthResult<()> {
98    if count > 1000 {
99        Err(error(
100            "Groups cannot contain more than 1000 direct members",
101            "invalidValue",
102        ))
103    } else {
104        Ok(())
105    }
106}
107/// Select the incremental fast path without dropping validation of overwritten adds.
108/// # Errors
109/// Returns a SCIM error for malformed incremental member values.
110pub fn prepare_scim_group_patch(
111    operations: Vec<ScimGroupPatchOperation>,
112) -> AuthResult<ScimGroupPatch> {
113    let mut desired = Vec::<(String, bool)>::new();
114    let mut validate = Vec::new();
115    for operation in &operations {
116        let Some(path) = operation
117            .path
118            .as_deref()
119            .map(trim)
120            .filter(|path| !path.is_empty())
121        else {
122            return Ok(ScimGroupPatch::Full(operations));
123        };
124        let (ids, wanted) = if normalized_path(path).eq_ignore_ascii_case("members") {
125            if operation.op == ScimGroupPatchKind::Replace || operation.value.is_none() {
126                return Ok(ScimGroupPatch::Full(operations));
127            }
128            (
129                members(operation.value.as_ref())?,
130                operation.op == ScimGroupPatchKind::Add,
131            )
132        } else if operation.op == ScimGroupPatchKind::Remove {
133            let Some(id) = member_path(path) else {
134                return Ok(ScimGroupPatch::Full(operations));
135            };
136            (vec![id], false)
137        } else {
138            return Ok(ScimGroupPatch::Full(operations));
139        };
140        for id in ids {
141            if wanted && !validate.contains(&id) {
142                validate.push(id.clone());
143            }
144            if let Some((_, value)) = desired.iter_mut().find(|(existing, _)| *existing == id) {
145                *value = wanted;
146            } else {
147                desired.push((id, wanted));
148            }
149        }
150    }
151    Ok(ScimGroupPatch::Incremental { desired, validate })
152}
153fn scalar(value: Option<&JsValue>, attribute: &str) -> AuthResult<String> {
154    let value = match value {
155        Some(JsValue::Array(values)) if values.len() == 1 => values.first(),
156        other => other,
157    };
158    value
159        .and_then(JsValue::as_str)
160        .map(trim)
161        .filter(|value| !value.is_empty())
162        .map(str::to_owned)
163        .ok_or_else(|| {
164            error(
165                format!("{attribute} must be a non-empty string"),
166                "invalidValue",
167            )
168        })
169}
170fn attribute(
171    op: ScimGroupPatchKind,
172    path: &str,
173    value: Option<&JsValue>,
174    name: &mut String,
175    external: &mut Option<String>,
176) -> AuthResult<()> {
177    let normalized = normalized_path(path).to_lowercase();
178    if matches!(normalized.as_str(), "id" | "schemas" | "meta") || normalized.starts_with("meta.") {
179        return Err(error(format!("{path} is read-only"), "mutability"));
180    }
181    match normalized.as_str() {
182        "displayname" => {
183            if op == ScimGroupPatchKind::Remove {
184                return Err(error(
185                    "displayName is required and cannot be removed",
186                    "mutability",
187                ));
188            }
189            *name = scalar(value, "displayName")?;
190        }
191        "externalid" => {
192            *external = if op == ScimGroupPatchKind::Remove {
193                None
194            } else {
195                Some(scalar(value, "externalId")?)
196            }
197        }
198        _ => return Err(error("Unsupported Group PATCH path", "invalidPath")),
199    }
200    Ok(())
201}
202fn scoped_key(parts: &[&str]) -> AuthResult<String> {
203    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
204    use sha2::{Digest, Sha256};
205    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(serde_json::to_vec(parts)?)))
206}
207/// Apply a full plan to the Group snapshot read under its mutation fence.
208/// # Errors
209/// Returns the reference's mutability, noTarget, invalidPath, and invalidValue errors.
210pub fn apply_scim_group_patch(
211    group: &ScimGroupRecord,
212    current: &[String],
213    operations: &[ScimGroupPatchOperation],
214) -> AuthResult<ReplaceScimGroup> {
215    let mut ids = current.iter().fold(Vec::new(), |mut ids, id| {
216        if !ids.contains(id) {
217            ids.push(id.clone());
218        }
219        ids
220    });
221    let mut name = group.display_name.clone();
222    let mut external = group.external_id.clone();
223    for operation in operations {
224        let path = operation
225            .path
226            .as_deref()
227            .map(trim)
228            .filter(|path| !path.is_empty());
229        let Some(path) = path else {
230            if operation.op == ScimGroupPatchKind::Remove {
231                return Err(error(
232                    "A pathless remove operation has no target",
233                    "noTarget",
234                ));
235            }
236            // The reference treats Date as a record, then iterates its own
237            // enumerable properties. A trusted Date has none.
238            if matches!(operation.value, Some(JsValue::Date(_))) {
239                continue;
240            }
241            let Some(JsValue::Object(values)) = operation.value.as_ref() else {
242                return Err(error(
243                    "A pathless Group PATCH value must be an object",
244                    "invalidValue",
245                ));
246            };
247            for (key, value) in values {
248                match key.to_lowercase().as_str() {
249                    "id" => {
250                        if value.as_str() != Some(group.id.as_str()) {
251                            return Err(error("A Group PATCH cannot change id", "mutability"));
252                        }
253                    }
254                    "schemas" | "meta" => {}
255                    "members" => {
256                        let added = members(Some(value))?;
257                        if operation.op == ScimGroupPatchKind::Replace {
258                            ids.clear();
259                        }
260                        for id in added {
261                            if !ids.contains(&id) {
262                                ids.push(id);
263                            }
264                        }
265                    }
266                    _ => attribute(operation.op, key, Some(value), &mut name, &mut external)?,
267                }
268            }
269            continue;
270        };
271        if normalized_path(path).eq_ignore_ascii_case("members") {
272            if operation.op == ScimGroupPatchKind::Remove && operation.value.is_none() {
273                ids.clear();
274                continue;
275            }
276            let requested = members(operation.value.as_ref())?;
277            if operation.op == ScimGroupPatchKind::Replace {
278                ids.clear();
279            }
280            for id in requested {
281                if operation.op == ScimGroupPatchKind::Remove {
282                    ids.retain(|existing| *existing != id);
283                } else if !ids.contains(&id) {
284                    ids.push(id);
285                }
286            }
287            continue;
288        }
289        if operation.op == ScimGroupPatchKind::Remove
290            && let Some(id) = member_path(path)
291        {
292            ids.retain(|existing| *existing != id);
293            continue;
294        }
295        attribute(
296            operation.op,
297            path,
298            operation.value.as_ref(),
299            &mut name,
300            &mut external,
301        )?;
302    }
303    cardinality(ids.len())?;
304    Ok(ReplaceScimGroup {
305        connection_id: group.connection_id.clone(),
306        provisioning_domain_id: group.provisioning_domain_id.clone(),
307        display_name_key: scoped_key(&[
308            "scim-group-display-name",
309            &group.connection_id,
310            &name.to_lowercase(),
311        ])?,
312        external_id_key: external
313            .as_ref()
314            .filter(|value| !value.is_empty())
315            .map(|value| scoped_key(&["scim-group-external-id", &group.connection_id, value]))
316            .transpose()?,
317        display_name: name,
318        external_id: external,
319        member_ids: ids,
320    })
321}