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