Domain managers can add and remove the members of the user groups attached to the domains they manage — without being global administrators. Previously, changing a user's group membership required write access on the User object, which lives in the global (root) folder and is reserved to administrators. Membership is now managed from the group side, where a domain manager already has authority.
User groups are attached to a folder (a domain). Membership is the reverse side of the User ↔ UserGroup relation, so it can be authorized on the group rather than on the user:
- Editing membership is gated by
change_usergroupon the group's folder, not bychange_user(which is global-admin-only). A domain manager holdschange_usergroupon their domain (and, recursively, subdomains). - Only the membership link (the M2M through-row) is written — no
Userattribute is ever modified this way. A domain manager cannot edit user accounts, only who belongs to their domain's groups. - Adding a user to a domain group only grants that group's domain-scoped roles; it cannot escalate access beyond the manager's own authority.
- The last direct member of the built-in
BI-UG-ADMadministrators group is protected: membership management can never empty it (mirroring the safeguard on the user form).
This preserves the single golden source of access control (role assignments) and the existing RBAC model — see Access control model. The user-side form enforces the same change_usergroup-per-group-folder rule on membership changes, along with the broader account-protection invariants — see User Account Security Policy.
From a user group's detail page:
- Add members — the button opens a picker listing users not already in the group. Search, paginate, select one or many, then confirm. Inactive users are hidden by default (toggle to include them; they show an "inactive" badge).
- Remove members — in the group's Users tab, select one or more members and click Remove from group.
Both controls appear only for users who can change the group (i.e. change_usergroup on the group's folder).
The picker is a general-purpose, scalable selector — it never loads the whole directory into the browser:
- Server-side search and pagination (a "show N entries" control) via the
/{model}/autocompleteendpoint, so it scales to very large directories. - A persistent selection tray that survives paging and searching.
Both actions are gated by change_usergroup on the group's folder:
| Method | Endpoint | Body |
|---|---|---|
| POST | /user-groups/{id}/add-members |
{ "users": [<uuid>, …] } |
| POST | /user-groups/{id}/remove-members |
{ "users": [<uuid>, …] } |
The membership feature is built from reusable pieces so other models can adopt the same pattern:
- Backend —
AutocompleteMixin(core/views.py) plus thebuild_autocomplete_serializerfactory (core/serializers.py) give any viewset a lightweight, server-paginatedautocompleteaction. A model becomes pickable by mixing it in and settingautocomplete_fields(or overridingautocomplete_serializer_class). The generic/{model}/autocompletefrontend proxy route forwards to it. - Frontend —
EntityPickerModalplus theopenEntityPickerhelper ($lib/utils/entityPicker.ts) provide the scalable multi-select. Removal reusesModelTable's opt-inselectablemode together with aremoveFromParentconfig on a reverse-foreign-key field incrud.ts, which POSTs the selected ids to a parent action endpoint.