Skip to content

Commit 7473746

Browse files
authored
Add support for group membership replace ops (#61)
1 parent 8409c94 commit 7473746

File tree

2 files changed

+154
-28
lines changed

2 files changed

+154
-28
lines changed

core/src/in_memory_provider_store.rs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,12 +1347,12 @@ mod test {
13471347
.await
13481348
.unwrap();
13491349

1350-
let patched_group2: StoredParts<Group> =
1350+
let patched_group5: StoredParts<Group> =
13511351
result_as_resource(result).await.unwrap();
1352-
group_is_durably_stored(&ctx, &patched_group2.resource).await;
1352+
group_is_durably_stored(&ctx, &patched_group5.resource).await;
13531353

13541354
let members =
1355-
patched_group2.resource.members.expect("group has members");
1355+
patched_group5.resource.members.expect("group has members");
13561356
assert_eq!(members.len(), 1);
13571357
let membership_ids: Vec<_> = members
13581358
.into_iter()
@@ -1364,5 +1364,58 @@ mod test {
13641364
membership_ids
13651365
.contains(&(jim.id.clone(), ResourceType::User.to_string()))
13661366
);
1367+
1368+
// Replace group members via replace op
1369+
1370+
let body = json!({
1371+
"schemas": [
1372+
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
1373+
],
1374+
"Operations": [
1375+
{
1376+
"op": "replace",
1377+
"path": "members",
1378+
"value": [
1379+
{
1380+
"value": dwight.id,
1381+
"display": dwight.name
1382+
},
1383+
{
1384+
"value": dwight.id,
1385+
"display": dwight.name
1386+
},
1387+
]
1388+
}
1389+
]
1390+
});
1391+
1392+
let result = ctx
1393+
.client
1394+
.patch(format!(
1395+
"{}/Groups/{}",
1396+
ctx.base_url, patched_group.resource.id
1397+
))
1398+
.json(&body)
1399+
.send()
1400+
.await
1401+
.unwrap();
1402+
1403+
let patched_group6: StoredParts<Group> =
1404+
result_as_resource(result).await.unwrap();
1405+
group_is_durably_stored(&ctx, &patched_group6.resource).await;
1406+
1407+
let members =
1408+
patched_group6.resource.members.expect("group has members");
1409+
assert_eq!(members.len(), 1);
1410+
let membership_ids: Vec<_> = members
1411+
.into_iter()
1412+
.map(|member| {
1413+
(member.value.unwrap(), member.resource_type.unwrap())
1414+
})
1415+
.collect();
1416+
assert!(
1417+
membership_ids
1418+
.contains(&(dwight.id.clone(), ResourceType::User.to_string()))
1419+
);
13671420
}
13681421
}

core/src/patch.rs

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ pub enum PatchRequestError {
1919
#[serde(tag = "op")]
2020
pub enum PatchOp {
2121
#[serde(rename = "replace")]
22-
// NB: replace ops have optional paths but we don't support it yet
23-
Replace { value: serde_json::Value },
22+
Replace { path: Option<String>, value: serde_json::Value },
2423
#[serde(rename = "add")]
2524
Add { path: Option<String>, value: serde_json::Value },
2625
#[serde(rename = "remove")]
@@ -58,7 +57,7 @@ impl PatchRequest {
5857
let mut updated_user = stored_user.clone();
5958

6059
for patch_op in &self.operations {
61-
let PatchOp::Replace { value } = patch_op else {
60+
let PatchOp::Replace { path: _, value } = patch_op else {
6261
return Err(PatchRequestError::Unsupported(
6362
"only the replace op is supported for users".to_string(),
6463
));
@@ -94,9 +93,11 @@ impl PatchRequest {
9493

9594
for patch_op in &self.operations {
9695
match patch_op {
97-
PatchOp::Replace { value } => {
98-
apply_group_replace_op(value, &mut updated_group)?
99-
}
96+
PatchOp::Replace { path, value } => apply_group_replace_op(
97+
path.as_ref(),
98+
value,
99+
&mut updated_group,
100+
)?,
100101
PatchOp::Add { path, value } => apply_group_add_op(
101102
path.as_deref(),
102103
value,
@@ -113,31 +114,80 @@ impl PatchRequest {
113114
}
114115

115116
fn apply_group_replace_op(
117+
path: Option<&String>,
116118
value: &serde_json::Value,
117119
group: &mut StoredParts<Group>,
118120
) -> Result<(), PatchRequestError> {
119-
#[derive(Debug, Deserialize)]
120-
#[serde(rename_all = "camelCase")]
121-
struct DisplayName {
122-
id: String,
123-
display_name: String,
124-
}
121+
match path {
122+
// Validate that we are replacing a groups members
123+
Some(path) => {
124+
#[derive(Debug, Deserialize)]
125+
#[serde(rename_all = "camelCase")]
126+
struct Member {
127+
value: String,
128+
}
125129

126-
let Ok(change) = serde_json::from_value::<DisplayName>(value.clone())
127-
else {
128-
return Err(PatchRequestError::Unsupported(
129-
"only replacing a groups displayName is supported".to_string(),
130-
));
131-
};
130+
if !path.eq_ignore_ascii_case("members") {
131+
return Err(PatchRequestError::Unsupported(
132+
"only replacing a groups members path is supported"
133+
.to_string(),
134+
));
135+
}
132136

133-
if !group.resource.id.eq_ignore_ascii_case(&change.id) {
134-
return Err(PatchRequestError::Invalid(format!(
135-
"unexpected group id {}",
136-
change.id
137-
)));
138-
}
137+
let Ok(patch_members) =
138+
serde_json::from_value::<Vec<Member>>(value.clone())
139+
else {
140+
return Err(PatchRequestError::Invalid(
141+
"members being replaced in a group require a value field"
142+
.to_string(),
143+
));
144+
};
139145

140-
group.resource.display_name = change.display_name;
146+
let new_members: IdOrdMap<GroupMember> = patch_members
147+
.into_iter()
148+
.map(|member| GroupMember {
149+
resource_type: Some(ResourceType::User.to_string()),
150+
value: Some(member.value),
151+
})
152+
.collect();
153+
group.resource.members =
154+
(!new_members.is_empty()).then_some(new_members);
155+
}
156+
157+
// RFC 7664 § 3.5.2.3:
158+
//
159+
// If the "path" parameter is omitted, the target is assumed to be the
160+
// resource itself. In this case, the "value" attribute SHALL contain a
161+
// list of one or more attributes that are to be replaced.
162+
None => {
163+
#[derive(Debug, Deserialize)]
164+
#[serde(rename_all = "camelCase")]
165+
struct DisplayName {
166+
id: String,
167+
display_name: String,
168+
}
169+
170+
// We currently only support changing a display name
171+
let Ok(change) =
172+
serde_json::from_value::<DisplayName>(value.clone())
173+
else {
174+
return Err(PatchRequestError::Unsupported(
175+
"only replacing a displayName is supported when not
176+
including a path"
177+
.to_string(),
178+
));
179+
};
180+
181+
if !group.resource.id.eq_ignore_ascii_case(&change.id) {
182+
return Err(PatchRequestError::Invalid(format!(
183+
"unexpected group id {}",
184+
change.id
185+
)));
186+
}
187+
188+
group.resource.display_name = change.display_name;
189+
}
190+
}
141191

142192
Ok(())
143193
}
@@ -285,6 +335,29 @@ mod test {
285335
serde_json::from_value::<PatchRequest>(json).unwrap();
286336
}
287337

338+
#[test]
339+
fn test_parse_group_members_replace_op() {
340+
let json = json!({
341+
"schemas": [
342+
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
343+
],
344+
"Operations": [
345+
{
346+
"op": "replace",
347+
"path": "members",
348+
"value": [
349+
{
350+
"value": "abf4dd94-a4c0-4f67-89c9-76b03340cb9b",
351+
"display": "dakota@example.com"
352+
}
353+
]
354+
}
355+
]
356+
});
357+
358+
serde_json::from_value::<PatchRequest>(json).unwrap();
359+
}
360+
288361
#[test]
289362
fn test_parse_group_membership_ops() {
290363
let json = json!({

0 commit comments

Comments
 (0)