kanidmd_lib/server/
scim.rs

1use crate::prelude::*;
2use crate::schema::{SchemaAttribute, SchemaTransaction};
3use crate::server::batch_modify::{BatchModifyEvent, ModSetValid};
4use crate::server::ValueSetResolveStatus;
5use crate::valueset::*;
6use kanidm_proto::scim_v1::client::{ScimEntryPostGeneric, ScimEntryPutGeneric};
7use kanidm_proto::scim_v1::JsonValue;
8use std::collections::BTreeMap;
9
10#[derive(Debug)]
11pub struct ScimEntryPutEvent {
12    /// The identity performing the change.
13    pub(crate) ident: Identity,
14
15    // future - etags to detect version changes.
16    /// The target entry that will be changed
17    pub(crate) target: Uuid,
18    /// Update an attribute to contain the following value state.
19    /// If the attribute is None, it is removed.
20    pub(crate) attrs: BTreeMap<Attribute, Option<ValueSet>>,
21
22    /// If an effective access check should be carried out post modification
23    /// of the entries
24    pub(crate) effective_access_check: bool,
25}
26
27impl ScimEntryPutEvent {
28    pub fn try_from(
29        ident: Identity,
30        entry: ScimEntryPutGeneric,
31        qs: &mut QueryServerWriteTransaction,
32    ) -> Result<Self, OperationError> {
33        let target = entry.id;
34
35        let attrs = entry
36            .attrs
37            .into_iter()
38            .map(|(attr, json_value)| {
39                qs.resolve_scim_json_put(&attr, json_value)
40                    .map(|kani_value| (attr, kani_value))
41            })
42            .collect::<Result<_, _>>()?;
43
44        let query = entry.query;
45
46        Ok(ScimEntryPutEvent {
47            ident,
48            target,
49            attrs,
50            effective_access_check: query.ext_access_check,
51        })
52    }
53}
54
55#[derive(Debug)]
56pub struct ScimCreateEvent {
57    pub(crate) ident: Identity,
58    pub(crate) entry: EntryInitNew,
59}
60
61impl ScimCreateEvent {
62    pub fn try_from(
63        ident: Identity,
64        classes: &[EntryClass],
65        entry: ScimEntryPostGeneric,
66        qs: &mut QueryServerWriteTransaction,
67    ) -> Result<Self, OperationError> {
68        let mut entry = entry
69            .attrs
70            .into_iter()
71            .map(|(attr, json_value)| {
72                qs.resolve_scim_json_post(&attr, json_value)
73                    .map(|kani_value| (attr, kani_value))
74            })
75            .collect::<Result<EntryInitNew, _>>()?;
76
77        if !classes.is_empty() {
78            let classes = ValueSetIutf8::from_iter(classes.iter().map(|cls| cls.as_ref()))
79                .ok_or(OperationError::SC0027ClassSetInvalid)?;
80
81            entry.set_ava_set(&Attribute::Class, classes);
82        }
83
84        Ok(ScimCreateEvent { ident, entry })
85    }
86}
87
88#[derive(Debug)]
89pub struct ScimDeleteEvent {
90    /// The identity performing the change.
91    pub(crate) ident: Identity,
92
93    // future - etags to detect version changes.
94    /// The target entry that will be changed
95    pub(crate) target: Uuid,
96
97    /// The class of the target entry.
98    pub(crate) class: EntryClass,
99}
100
101impl ScimDeleteEvent {
102    pub fn new(ident: Identity, target: Uuid, class: EntryClass) -> Self {
103        ScimDeleteEvent {
104            ident,
105            target,
106            class,
107        }
108    }
109}
110
111impl QueryServerWriteTransaction<'_> {
112    /// SCIM PUT is the handler where a single entry is updated. In a SCIM PUT request
113    /// the request defines the state of an attribute in entirety for the update. This
114    /// means if the caller wants to add one email address, they must PUT all existing
115    /// addresses in addition to the new one.
116    pub fn scim_put(
117        &mut self,
118        scim_entry_put: ScimEntryPutEvent,
119    ) -> Result<ScimEntryKanidm, OperationError> {
120        let ScimEntryPutEvent {
121            ident,
122            target,
123            attrs,
124            effective_access_check,
125        } = scim_entry_put;
126
127        // This function transforms the put event into a modify event.
128        let mods_invalid: ModifyList<ModifyInvalid> = attrs.into();
129
130        let mods_valid = mods_invalid
131            .validate(self.get_schema())
132            .map_err(OperationError::SchemaViolation)?;
133
134        let mut modset = ModSetValid::default();
135
136        modset.insert(target, mods_valid);
137
138        let modify_event = BatchModifyEvent {
139            ident: ident.clone(),
140            modset,
141        };
142
143        // dispatch to batch modify
144        self.batch_modify(&modify_event)?;
145
146        // Now get the entry. We handle a lot of the errors here nicely,
147        // but if we got to this point, they really can't happen.
148        let filter_intent = filter!(f_and!([f_eq(Attribute::Uuid, PartialValue::Uuid(target))]));
149
150        let f_intent_valid = filter_intent
151            .validate(self.get_schema())
152            .map_err(OperationError::SchemaViolation)?;
153
154        let f_valid = f_intent_valid.clone().into_ignore_hidden();
155
156        let se = SearchEvent {
157            ident,
158            filter: f_valid,
159            filter_orig: f_intent_valid,
160            // Return all attributes, even ones we didn't affect
161            attrs: None,
162            effective_access_check,
163        };
164
165        let mut vs = self.search_ext(&se)?;
166        match vs.pop() {
167            Some(entry) if vs.is_empty() => entry.to_scim_kanidm(self),
168            _ => {
169                if vs.is_empty() {
170                    Err(OperationError::NoMatchingEntries)
171                } else {
172                    // Multiple entries matched, should not be possible!
173                    Err(OperationError::UniqueConstraintViolation)
174                }
175            }
176        }
177    }
178
179    pub fn scim_create(
180        &mut self,
181        scim_create: ScimCreateEvent,
182    ) -> Result<ScimEntryKanidm, OperationError> {
183        let ScimCreateEvent { ident, entry } = scim_create;
184
185        let create_event = CreateEvent {
186            ident,
187            entries: vec![entry],
188            return_created_uuids: true,
189        };
190
191        let changed_uuids = self.create(&create_event)?;
192
193        let mut changed_uuids = changed_uuids.ok_or(OperationError::SC0028CreatedUuidsInvalid)?;
194
195        let target = if let Some(target) = changed_uuids.pop() {
196            if !changed_uuids.is_empty() {
197                // Too many results!
198                return Err(OperationError::UniqueConstraintViolation);
199            }
200
201            target
202        } else {
203            // No results!
204            return Err(OperationError::NoMatchingEntries);
205        };
206
207        // Now get the entry. We handle a lot of the errors here nicely,
208        // but if we got to this point, they really can't happen.
209        let filter_intent = filter!(f_and!([f_eq(Attribute::Uuid, PartialValue::Uuid(target))]));
210
211        let f_intent_valid = filter_intent
212            .validate(self.get_schema())
213            .map_err(OperationError::SchemaViolation)?;
214
215        let f_valid = f_intent_valid.clone().into_ignore_hidden();
216
217        let se = SearchEvent {
218            ident: create_event.ident,
219            filter: f_valid,
220            filter_orig: f_intent_valid,
221            // Return all attributes
222            attrs: None,
223            effective_access_check: false,
224        };
225
226        let mut vs = self.search_ext(&se)?;
227        match vs.pop() {
228            Some(entry) if vs.is_empty() => entry.to_scim_kanidm(self),
229            _ => {
230                if vs.is_empty() {
231                    Err(OperationError::NoMatchingEntries)
232                } else {
233                    // Multiple entries matched, should not be possible!
234                    Err(OperationError::UniqueConstraintViolation)
235                }
236            }
237        }
238    }
239
240    pub fn scim_delete(&mut self, scim_delete: ScimDeleteEvent) -> Result<(), OperationError> {
241        let ScimDeleteEvent {
242            ident,
243            target,
244            class,
245        } = scim_delete;
246
247        let filter_intent = filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(target)));
248        let f_intent_valid = filter_intent
249            .validate(self.get_schema())
250            .map_err(OperationError::SchemaViolation)?;
251
252        let filter = filter!(f_and!([
253            f_eq(Attribute::Uuid, PartialValue::Uuid(target)),
254            f_eq(Attribute::Class, class.into())
255        ]));
256        let f_valid = filter
257            .validate(self.get_schema())
258            .map_err(OperationError::SchemaViolation)?;
259
260        let de = DeleteEvent {
261            ident,
262            filter: f_valid,
263            filter_orig: f_intent_valid,
264        };
265
266        self.delete(&de)
267    }
268
269    pub(crate) fn resolve_scim_json_put(
270        &mut self,
271        attr: &Attribute,
272        value: Option<JsonValue>,
273    ) -> Result<Option<ValueSet>, OperationError> {
274        let schema = self.get_schema();
275        // Lookup the attr
276        let Some(schema_a) = schema.get_attributes().get(attr) else {
277            // No attribute of this name exists - fail fast, there is no point to
278            // proceed, as nothing can be satisfied.
279            return Err(OperationError::InvalidAttributeName(attr.to_string()));
280        };
281
282        let Some(value) = value else {
283            // It's a none so the value needs to be unset, and the attr DOES exist in
284            // schema.
285            return Ok(None);
286        };
287
288        self.resolve_scim_json(schema_a, value).map(Some)
289    }
290
291    pub(crate) fn resolve_scim_json_post(
292        &mut self,
293        attr: &Attribute,
294        value: JsonValue,
295    ) -> Result<ValueSet, OperationError> {
296        let schema = self.get_schema();
297        // Lookup the attr
298        let Some(schema_a) = schema.get_attributes().get(attr) else {
299            // No attribute of this name exists - fail fast, there is no point to
300            // proceed, as nothing can be satisfied.
301            return Err(OperationError::InvalidAttributeName(attr.to_string()));
302        };
303
304        self.resolve_scim_json(schema_a, value)
305    }
306
307    fn resolve_scim_json(
308        &mut self,
309        schema_a: &SchemaAttribute,
310        value: JsonValue,
311    ) -> Result<ValueSet, OperationError> {
312        let resolve_status = match schema_a.syntax {
313            SyntaxType::Utf8String => ValueSetUtf8::from_scim_json_put(value),
314            SyntaxType::Utf8StringInsensitive => ValueSetIutf8::from_scim_json_put(value),
315            SyntaxType::Uuid => ValueSetUuid::from_scim_json_put(value),
316            SyntaxType::Boolean => ValueSetBool::from_scim_json_put(value),
317            SyntaxType::SyntaxId => ValueSetSyntax::from_scim_json_put(value),
318            SyntaxType::IndexId => ValueSetIndex::from_scim_json_put(value),
319            SyntaxType::ReferenceUuid => ValueSetRefer::from_scim_json_put(value),
320            SyntaxType::Utf8StringIname => ValueSetIname::from_scim_json_put(value),
321            SyntaxType::NsUniqueId => ValueSetNsUniqueId::from_scim_json_put(value),
322            SyntaxType::DateTime => ValueSetDateTime::from_scim_json_put(value),
323            SyntaxType::EmailAddress => ValueSetEmailAddress::from_scim_json_put(value),
324            SyntaxType::Url => ValueSetUrl::from_scim_json_put(value),
325            SyntaxType::OauthScope => ValueSetOauthScope::from_scim_json_put(value),
326            SyntaxType::OauthScopeMap => ValueSetOauthScopeMap::from_scim_json_put(value),
327            SyntaxType::OauthClaimMap => ValueSetOauthClaimMap::from_scim_json_put(value),
328            SyntaxType::UiHint => ValueSetUiHint::from_scim_json_put(value),
329            SyntaxType::CredentialType => ValueSetCredentialType::from_scim_json_put(value),
330            SyntaxType::Certificate => ValueSetCertificate::from_scim_json_put(value),
331            SyntaxType::SshKey => ValueSetSshKey::from_scim_json_put(value),
332            SyntaxType::Uint32 => ValueSetUint32::from_scim_json_put(value),
333            SyntaxType::Sha256 => ValueSetSha256::from_scim_json_put(value),
334
335            // Not Yet ... if ever
336            // SyntaxType::JsonFilter => ValueSetJsonFilter::from_scim_json_put(value),
337            SyntaxType::JsonFilter => Err(OperationError::InvalidAttribute(
338                "Json Filters are not able to be set.".to_string(),
339            )),
340            // Not Yet ... if ever.
341            SyntaxType::Json => Err(OperationError::InvalidAttribute(
342                "Json values are not able to be set.".to_string(),
343            )),
344            SyntaxType::Message => Err(OperationError::InvalidAttribute(
345                "Message values are not able to be set.".to_string(),
346            )),
347            // Can't be set currently as these are only internally generated for key-id's
348            // SyntaxType::HexString => ValueSetHexString::from_scim_json_put(value),
349            SyntaxType::HexString => Err(OperationError::InvalidAttribute(
350                "Hex strings are not able to be set.".to_string(),
351            )),
352
353            // Can't be set until we have better error handling in the set paths
354            // SyntaxType::Image => ValueSetImage::from_scim_json_put(value),
355            SyntaxType::Image => Err(OperationError::InvalidAttribute(
356                "Images are not able to be set.".to_string(),
357            )),
358
359            // Can't be set yet, mostly as I'm lazy
360            // SyntaxType::WebauthnAttestationCaList => {
361            //    ValueSetWebauthnAttestationCaList::from_scim_json_put(value)
362            // }
363            SyntaxType::WebauthnAttestationCaList => Err(OperationError::InvalidAttribute(
364                "Webauthn Attestation Ca Lists are not able to be set.".to_string(),
365            )),
366
367            // Syntax types that can not be submitted
368            SyntaxType::Credential => Err(OperationError::InvalidAttribute(
369                "Credentials are not able to be set.".to_string(),
370            )),
371            SyntaxType::SecretUtf8String => Err(OperationError::InvalidAttribute(
372                "Secrets are not able to be set.".to_string(),
373            )),
374            SyntaxType::SecurityPrincipalName => Err(OperationError::InvalidAttribute(
375                "SPNs are not able to be set.".to_string(),
376            )),
377            SyntaxType::Cid => Err(OperationError::InvalidAttribute(
378                "CIDs are not able to be set.".to_string(),
379            )),
380            SyntaxType::PrivateBinary => Err(OperationError::InvalidAttribute(
381                "Private Binaries are not able to be set.".to_string(),
382            )),
383            SyntaxType::IntentToken => Err(OperationError::InvalidAttribute(
384                "Intent Tokens are not able to be set.".to_string(),
385            )),
386            SyntaxType::Passkey => Err(OperationError::InvalidAttribute(
387                "Passkeys are not able to be set.".to_string(),
388            )),
389            SyntaxType::AttestedPasskey => Err(OperationError::InvalidAttribute(
390                "Attested Passkeys are not able to be set.".to_string(),
391            )),
392            SyntaxType::Session => Err(OperationError::InvalidAttribute(
393                "Sessions are not able to be set.".to_string(),
394            )),
395            SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute(
396                "Jws ES256 Private Keys are not able to be set.".to_string(),
397            )),
398            SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute(
399                "Jws RS256 Private Keys are not able to be set.".to_string(),
400            )),
401            SyntaxType::Oauth2Session => Err(OperationError::InvalidAttribute(
402                "Sessions are not able to be set.".to_string(),
403            )),
404            SyntaxType::TotpSecret => Err(OperationError::InvalidAttribute(
405                "TOTP Secrets are not able to be set.".to_string(),
406            )),
407            SyntaxType::ApiToken => Err(OperationError::InvalidAttribute(
408                "API Tokens are not able to be set.".to_string(),
409            )),
410            SyntaxType::AuditLogString => Err(OperationError::InvalidAttribute(
411                "Audit Strings are not able to be set.".to_string(),
412            )),
413            SyntaxType::EcKeyPrivate => Err(OperationError::InvalidAttribute(
414                "EC Private Keys are not able to be set.".to_string(),
415            )),
416            SyntaxType::KeyInternal => Err(OperationError::InvalidAttribute(
417                "Key Internal Structures are not able to be set.".to_string(),
418            )),
419            SyntaxType::ApplicationPassword => Err(OperationError::InvalidAttribute(
420                "Application Passwords are not able to be set.".to_string(),
421            )),
422        }?;
423
424        match resolve_status {
425            ValueSetResolveStatus::Resolved(vs) => Ok(vs),
426            ValueSetResolveStatus::NeedsResolution(vs_inter) => {
427                self.resolve_valueset_intermediate(vs_inter)
428            }
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::ScimEntryPutEvent;
436    use crate::prelude::*;
437    use kanidm_proto::scim_v1::client::ScimEntryPutKanidm;
438    use kanidm_proto::scim_v1::server::ScimReference;
439    use kanidm_proto::scim_v1::ScimMail;
440
441    #[qs_test]
442    async fn scim_put_basic(server: &QueryServer) {
443        let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
444
445        let idm_admin_entry = server_txn.internal_search_uuid(UUID_IDM_ADMIN).unwrap();
446
447        let idm_admin_ident = Identity::from_impersonate_entry_readwrite(idm_admin_entry);
448
449        // Make an entry.
450        let group_uuid = Uuid::new_v4();
451
452        // Add members to our groups to test reference handling in scim
453        let extra1_uuid = Uuid::new_v4();
454        let extra2_uuid = Uuid::new_v4();
455        let extra3_uuid = Uuid::new_v4();
456
457        let e1 = entry_init!(
458            (Attribute::Class, EntryClass::Object.to_value()),
459            (Attribute::Class, EntryClass::Group.to_value()),
460            (Attribute::Name, Value::new_iname("testgroup")),
461            (Attribute::Uuid, Value::Uuid(group_uuid))
462        );
463
464        let e2 = entry_init!(
465            (Attribute::Class, EntryClass::Object.to_value()),
466            (Attribute::Class, EntryClass::Group.to_value()),
467            (Attribute::Name, Value::new_iname("extra_1")),
468            (Attribute::Uuid, Value::Uuid(extra1_uuid))
469        );
470
471        let e3 = entry_init!(
472            (Attribute::Class, EntryClass::Object.to_value()),
473            (Attribute::Class, EntryClass::Group.to_value()),
474            (Attribute::Name, Value::new_iname("extra_2")),
475            (Attribute::Uuid, Value::Uuid(extra2_uuid))
476        );
477
478        let e4 = entry_init!(
479            (Attribute::Class, EntryClass::Object.to_value()),
480            (Attribute::Class, EntryClass::Group.to_value()),
481            (Attribute::Name, Value::new_iname("extra_3")),
482            (Attribute::Uuid, Value::Uuid(extra3_uuid))
483        );
484
485        assert!(server_txn.internal_create(vec![e1, e2, e3, e4]).is_ok());
486
487        // Set attrs
488        let test_mails = vec![
489            ScimMail {
490                primary: true,
491                value: "test@test.test".to_string(),
492            },
493            ScimMail {
494                primary: false,
495                value: "test2@test.test".to_string(),
496            },
497        ];
498        let put = ScimEntryPutKanidm {
499            id: group_uuid,
500            attrs: [
501                (Attribute::Description, Some("Group Description".into())),
502                (
503                    Attribute::Mail,
504                    Some(ScimValueKanidm::Mail(test_mails.clone())),
505                ),
506            ]
507            .into(),
508        };
509
510        let put_generic = put.try_into().unwrap();
511        let put_event =
512            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
513                .expect("Failed to resolve data type");
514
515        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
516        let desc = updated_entry.attrs.get(&Attribute::Description).unwrap();
517        let mails = updated_entry.attrs.get(&Attribute::Mail).unwrap();
518
519        match desc {
520            ScimValueKanidm::String(gdesc) if gdesc == "Group Description" => {}
521            _ => unreachable!("Expected a string"),
522        };
523
524        let ScimValueKanidm::Mail(mails) = mails else {
525            unreachable!("Expected an email")
526        };
527
528        // asserts emails ⊂ test_mails
529        assert!(mails.iter().all(|mail| test_mails.contains(mail)));
530
531        // null removes attr
532        let put = ScimEntryPutKanidm {
533            id: group_uuid,
534            attrs: [(Attribute::Description, None)].into(),
535        };
536
537        let put_generic = put.try_into().unwrap();
538        let put_event =
539            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
540                .expect("Failed to resolve data type");
541
542        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
543        assert!(!updated_entry.attrs.contains_key(&Attribute::Description));
544
545        // set one
546        let put = ScimEntryPutKanidm {
547            id: group_uuid,
548            attrs: [(
549                Attribute::Member,
550                Some(ScimValueKanidm::EntryReferences(vec![ScimReference {
551                    uuid: extra1_uuid,
552                    // Doesn't matter what this is, because there is a UUID, it's ignored
553                    value: String::default(),
554                }])),
555            )]
556            .into(),
557        };
558
559        let put_generic = put.try_into().unwrap();
560        let put_event =
561            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
562                .expect("Failed to resolve data type");
563
564        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
565        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
566
567        trace!(?members);
568
569        match members {
570            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 1 => {
571                assert!(member_set.contains(&ScimReference {
572                    uuid: extra1_uuid,
573                    value: "extra_1@example.com".to_string(),
574                }));
575            }
576            _ => unreachable!("Expected 1 member"),
577        };
578
579        // set many
580        let put = ScimEntryPutKanidm {
581            id: group_uuid,
582            attrs: [(
583                Attribute::Member,
584                Some(ScimValueKanidm::EntryReferences(vec![
585                    ScimReference {
586                        uuid: extra1_uuid,
587                        value: String::default(),
588                    },
589                    ScimReference {
590                        uuid: extra2_uuid,
591                        value: String::default(),
592                    },
593                    ScimReference {
594                        uuid: extra3_uuid,
595                        value: String::default(),
596                    },
597                ])),
598            )]
599            .into(),
600        };
601
602        let put_generic = put.try_into().unwrap();
603        let put_event =
604            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
605                .expect("Failed to resolve data type");
606
607        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
608        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
609
610        trace!(?members);
611
612        match members {
613            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 3 => {
614                assert!(member_set.contains(&ScimReference {
615                    uuid: extra1_uuid,
616                    value: "extra_1@example.com".to_string(),
617                }));
618                assert!(member_set.contains(&ScimReference {
619                    uuid: extra2_uuid,
620                    value: "extra_2@example.com".to_string(),
621                }));
622                assert!(member_set.contains(&ScimReference {
623                    uuid: extra3_uuid,
624                    value: "extra_3@example.com".to_string(),
625                }));
626            }
627            _ => unreachable!("Expected 3 members"),
628        };
629
630        // set many with a removal
631        let put = ScimEntryPutKanidm {
632            id: group_uuid,
633            attrs: [(
634                Attribute::Member,
635                Some(ScimValueKanidm::EntryReferences(vec![
636                    ScimReference {
637                        uuid: extra1_uuid,
638                        value: String::default(),
639                    },
640                    ScimReference {
641                        uuid: extra3_uuid,
642                        value: String::default(),
643                    },
644                ])),
645            )]
646            .into(),
647        };
648
649        let put_generic = put.try_into().unwrap();
650        let put_event =
651            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
652                .expect("Failed to resolve data type");
653
654        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
655        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
656
657        trace!(?members);
658
659        match members {
660            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 2 => {
661                assert!(member_set.contains(&ScimReference {
662                    uuid: extra1_uuid,
663                    value: "extra_1@example.com".to_string(),
664                }));
665                assert!(member_set.contains(&ScimReference {
666                    uuid: extra3_uuid,
667                    value: "extra_3@example.com".to_string(),
668                }));
669                // Member 2 is gone
670                assert!(!member_set.contains(&ScimReference {
671                    uuid: extra2_uuid,
672                    value: "extra_2@example.com".to_string(),
673                }));
674            }
675            _ => unreachable!("Expected 2 members"),
676        };
677
678        // empty set removes attr
679        let put = ScimEntryPutKanidm {
680            id: group_uuid,
681            attrs: [(Attribute::Member, None)].into(),
682        };
683
684        let put_generic = put.try_into().unwrap();
685        let put_event =
686            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
687                .expect("Failed to resolve data type");
688
689        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
690        assert!(!updated_entry.attrs.contains_key(&Attribute::Member));
691    }
692}