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            // Can't be set currently as these are only internally generated for key-id's
338            // SyntaxType::HexString => ValueSetHexString::from_scim_json_put(value),
339            SyntaxType::HexString => Err(OperationError::InvalidAttribute(
340                "Hex strings are not able to be set.".to_string(),
341            )),
342
343            // Can't be set until we have better error handling in the set paths
344            // SyntaxType::Image => ValueSetImage::from_scim_json_put(value),
345            SyntaxType::Image => Err(OperationError::InvalidAttribute(
346                "Images are not able to be set.".to_string(),
347            )),
348
349            // Can't be set yet, mostly as I'm lazy
350            // SyntaxType::WebauthnAttestationCaList => {
351            //    ValueSetWebauthnAttestationCaList::from_scim_json_put(value)
352            // }
353            SyntaxType::WebauthnAttestationCaList => Err(OperationError::InvalidAttribute(
354                "Webauthn Attestation Ca Lists are not able to be set.".to_string(),
355            )),
356
357            // Syntax types that can not be submitted
358            SyntaxType::Credential => Err(OperationError::InvalidAttribute(
359                "Credentials are not able to be set.".to_string(),
360            )),
361            SyntaxType::SecretUtf8String => Err(OperationError::InvalidAttribute(
362                "Secrets are not able to be set.".to_string(),
363            )),
364            SyntaxType::SecurityPrincipalName => Err(OperationError::InvalidAttribute(
365                "SPNs are not able to be set.".to_string(),
366            )),
367            SyntaxType::Cid => Err(OperationError::InvalidAttribute(
368                "CIDs are not able to be set.".to_string(),
369            )),
370            SyntaxType::PrivateBinary => Err(OperationError::InvalidAttribute(
371                "Private Binaries are not able to be set.".to_string(),
372            )),
373            SyntaxType::IntentToken => Err(OperationError::InvalidAttribute(
374                "Intent Tokens are not able to be set.".to_string(),
375            )),
376            SyntaxType::Passkey => Err(OperationError::InvalidAttribute(
377                "Passkeys are not able to be set.".to_string(),
378            )),
379            SyntaxType::AttestedPasskey => Err(OperationError::InvalidAttribute(
380                "Attested Passkeys are not able to be set.".to_string(),
381            )),
382            SyntaxType::Session => Err(OperationError::InvalidAttribute(
383                "Sessions are not able to be set.".to_string(),
384            )),
385            SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute(
386                "Jws ES256 Private Keys are not able to be set.".to_string(),
387            )),
388            SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute(
389                "Jws RS256 Private Keys are not able to be set.".to_string(),
390            )),
391            SyntaxType::Oauth2Session => Err(OperationError::InvalidAttribute(
392                "Sessions are not able to be set.".to_string(),
393            )),
394            SyntaxType::TotpSecret => Err(OperationError::InvalidAttribute(
395                "TOTP Secrets are not able to be set.".to_string(),
396            )),
397            SyntaxType::ApiToken => Err(OperationError::InvalidAttribute(
398                "API Tokens are not able to be set.".to_string(),
399            )),
400            SyntaxType::AuditLogString => Err(OperationError::InvalidAttribute(
401                "Audit Strings are not able to be set.".to_string(),
402            )),
403            SyntaxType::EcKeyPrivate => Err(OperationError::InvalidAttribute(
404                "EC Private Keys are not able to be set.".to_string(),
405            )),
406            SyntaxType::KeyInternal => Err(OperationError::InvalidAttribute(
407                "Key Internal Structures are not able to be set.".to_string(),
408            )),
409            SyntaxType::ApplicationPassword => Err(OperationError::InvalidAttribute(
410                "Application Passwords are not able to be set.".to_string(),
411            )),
412        }?;
413
414        match resolve_status {
415            ValueSetResolveStatus::Resolved(vs) => Ok(vs),
416            ValueSetResolveStatus::NeedsResolution(vs_inter) => {
417                self.resolve_valueset_intermediate(vs_inter)
418            }
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::ScimEntryPutEvent;
426    use crate::prelude::*;
427    use kanidm_proto::scim_v1::client::ScimEntryPutKanidm;
428    use kanidm_proto::scim_v1::server::ScimReference;
429    use kanidm_proto::scim_v1::ScimMail;
430
431    #[qs_test]
432    async fn scim_put_basic(server: &QueryServer) {
433        let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
434
435        let idm_admin_entry = server_txn.internal_search_uuid(UUID_IDM_ADMIN).unwrap();
436
437        let idm_admin_ident = Identity::from_impersonate_entry_readwrite(idm_admin_entry);
438
439        // Make an entry.
440        let group_uuid = Uuid::new_v4();
441
442        // Add members to our groups to test reference handling in scim
443        let extra1_uuid = Uuid::new_v4();
444        let extra2_uuid = Uuid::new_v4();
445        let extra3_uuid = Uuid::new_v4();
446
447        let e1 = entry_init!(
448            (Attribute::Class, EntryClass::Object.to_value()),
449            (Attribute::Class, EntryClass::Group.to_value()),
450            (Attribute::Name, Value::new_iname("testgroup")),
451            (Attribute::Uuid, Value::Uuid(group_uuid))
452        );
453
454        let e2 = entry_init!(
455            (Attribute::Class, EntryClass::Object.to_value()),
456            (Attribute::Class, EntryClass::Group.to_value()),
457            (Attribute::Name, Value::new_iname("extra_1")),
458            (Attribute::Uuid, Value::Uuid(extra1_uuid))
459        );
460
461        let e3 = entry_init!(
462            (Attribute::Class, EntryClass::Object.to_value()),
463            (Attribute::Class, EntryClass::Group.to_value()),
464            (Attribute::Name, Value::new_iname("extra_2")),
465            (Attribute::Uuid, Value::Uuid(extra2_uuid))
466        );
467
468        let e4 = entry_init!(
469            (Attribute::Class, EntryClass::Object.to_value()),
470            (Attribute::Class, EntryClass::Group.to_value()),
471            (Attribute::Name, Value::new_iname("extra_3")),
472            (Attribute::Uuid, Value::Uuid(extra3_uuid))
473        );
474
475        assert!(server_txn.internal_create(vec![e1, e2, e3, e4]).is_ok());
476
477        // Set attrs
478        let test_mails = vec![
479            ScimMail {
480                primary: true,
481                value: "test@test.test".to_string(),
482            },
483            ScimMail {
484                primary: false,
485                value: "test2@test.test".to_string(),
486            },
487        ];
488        let put = ScimEntryPutKanidm {
489            id: group_uuid,
490            attrs: [
491                (Attribute::Description, Some("Group Description".into())),
492                (
493                    Attribute::Mail,
494                    Some(ScimValueKanidm::Mail(test_mails.clone())),
495                ),
496            ]
497            .into(),
498        };
499
500        let put_generic = put.try_into().unwrap();
501        let put_event =
502            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
503                .expect("Failed to resolve data type");
504
505        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
506        let desc = updated_entry.attrs.get(&Attribute::Description).unwrap();
507        let mails = updated_entry.attrs.get(&Attribute::Mail).unwrap();
508
509        match desc {
510            ScimValueKanidm::String(gdesc) if gdesc == "Group Description" => {}
511            _ => unreachable!("Expected a string"),
512        };
513
514        let ScimValueKanidm::Mail(mails) = mails else {
515            unreachable!("Expected an email")
516        };
517
518        // asserts emails ⊂ test_mails
519        assert!(mails.iter().all(|mail| test_mails.contains(mail)));
520
521        // null removes attr
522        let put = ScimEntryPutKanidm {
523            id: group_uuid,
524            attrs: [(Attribute::Description, None)].into(),
525        };
526
527        let put_generic = put.try_into().unwrap();
528        let put_event =
529            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
530                .expect("Failed to resolve data type");
531
532        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
533        assert!(!updated_entry.attrs.contains_key(&Attribute::Description));
534
535        // set one
536        let put = ScimEntryPutKanidm {
537            id: group_uuid,
538            attrs: [(
539                Attribute::Member,
540                Some(ScimValueKanidm::EntryReferences(vec![ScimReference {
541                    uuid: extra1_uuid,
542                    // Doesn't matter what this is, because there is a UUID, it's ignored
543                    value: String::default(),
544                }])),
545            )]
546            .into(),
547        };
548
549        let put_generic = put.try_into().unwrap();
550        let put_event =
551            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
552                .expect("Failed to resolve data type");
553
554        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
555        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
556
557        trace!(?members);
558
559        match members {
560            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 1 => {
561                assert!(member_set.contains(&ScimReference {
562                    uuid: extra1_uuid,
563                    value: "extra_1@example.com".to_string(),
564                }));
565            }
566            _ => unreachable!("Expected 1 member"),
567        };
568
569        // set many
570        let put = ScimEntryPutKanidm {
571            id: group_uuid,
572            attrs: [(
573                Attribute::Member,
574                Some(ScimValueKanidm::EntryReferences(vec![
575                    ScimReference {
576                        uuid: extra1_uuid,
577                        value: String::default(),
578                    },
579                    ScimReference {
580                        uuid: extra2_uuid,
581                        value: String::default(),
582                    },
583                    ScimReference {
584                        uuid: extra3_uuid,
585                        value: String::default(),
586                    },
587                ])),
588            )]
589            .into(),
590        };
591
592        let put_generic = put.try_into().unwrap();
593        let put_event =
594            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
595                .expect("Failed to resolve data type");
596
597        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
598        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
599
600        trace!(?members);
601
602        match members {
603            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 3 => {
604                assert!(member_set.contains(&ScimReference {
605                    uuid: extra1_uuid,
606                    value: "extra_1@example.com".to_string(),
607                }));
608                assert!(member_set.contains(&ScimReference {
609                    uuid: extra2_uuid,
610                    value: "extra_2@example.com".to_string(),
611                }));
612                assert!(member_set.contains(&ScimReference {
613                    uuid: extra3_uuid,
614                    value: "extra_3@example.com".to_string(),
615                }));
616            }
617            _ => unreachable!("Expected 3 members"),
618        };
619
620        // set many with a removal
621        let put = ScimEntryPutKanidm {
622            id: group_uuid,
623            attrs: [(
624                Attribute::Member,
625                Some(ScimValueKanidm::EntryReferences(vec![
626                    ScimReference {
627                        uuid: extra1_uuid,
628                        value: String::default(),
629                    },
630                    ScimReference {
631                        uuid: extra3_uuid,
632                        value: String::default(),
633                    },
634                ])),
635            )]
636            .into(),
637        };
638
639        let put_generic = put.try_into().unwrap();
640        let put_event =
641            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
642                .expect("Failed to resolve data type");
643
644        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
645        let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
646
647        trace!(?members);
648
649        match members {
650            ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 2 => {
651                assert!(member_set.contains(&ScimReference {
652                    uuid: extra1_uuid,
653                    value: "extra_1@example.com".to_string(),
654                }));
655                assert!(member_set.contains(&ScimReference {
656                    uuid: extra3_uuid,
657                    value: "extra_3@example.com".to_string(),
658                }));
659                // Member 2 is gone
660                assert!(!member_set.contains(&ScimReference {
661                    uuid: extra2_uuid,
662                    value: "extra_2@example.com".to_string(),
663                }));
664            }
665            _ => unreachable!("Expected 2 members"),
666        };
667
668        // empty set removes attr
669        let put = ScimEntryPutKanidm {
670            id: group_uuid,
671            attrs: [(Attribute::Member, None)].into(),
672        };
673
674        let put_generic = put.try_into().unwrap();
675        let put_event =
676            ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
677                .expect("Failed to resolve data type");
678
679        let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
680        assert!(!updated_entry.attrs.contains_key(&Attribute::Member));
681    }
682}