1use 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}
91pub 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}
107pub 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}
207pub 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 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}