From e4081a8c1305bc8368d06219b6cfdd55560ad81a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 25 Aug 2026 20:43:37 +0100 Subject: [PATCH 1/7] feat(organisations): support deactivated user membership Adds `UserOrganisation.is_active`, defaulting to `True`. Deactivated members can still log in, but cannot access the organisation, do not see it in the organisation picker, and do not count towards the seat limit. Their roles, permissions and group memberships are retained, so reactivation restores access. Reactivation deliberately does not enforce the seat limit: it is driven by an external identity provider over SCIM, where failing the call would leave the provider and Flagsmith out of sync. Ref: #8368 --- api/api_keys/user.py | 5 + api/audit/views.py | 5 +- api/organisations/invites/views.py | 8 +- .../0061_add_user_organisation_is_active.py | 21 ++ api/organisations/models.py | 9 +- api/organisations/serializers.py | 3 +- api/organisations/task_helpers.py | 2 + api/organisations/views.py | 2 +- api/permissions/permission_service.py | 38 ++- api/sales_dashboard/views.py | 4 +- ...it_organisations_deactivated_membership.py | 245 ++++++++++++++++++ .../unit/users/test_unit_users_models.py | 2 +- api/users/abc.py | 7 + api/users/models.py | 38 ++- .../observability/_events-catalogue.md | 6 +- 15 files changed, 371 insertions(+), 24 deletions(-) create mode 100644 api/organisations/migrations/0061_add_user_organisation_is_active.py create mode 100644 api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py diff --git a/api/api_keys/user.py b/api/api_keys/user.py index 38e06fe72800..c5cc53b6d2b3 100644 --- a/api/api_keys/user.py +++ b/api/api_keys/user.py @@ -42,6 +42,11 @@ def is_master_api_key_user(self) -> bool: def organisations(self) -> QuerySet[Organisation]: return Organisation.objects.filter(id=self.key.organisation_id) # type: ignore[no-any-return] + def get_active_organisations(self) -> QuerySet[Organisation]: + # Master API keys are scoped to a single organisation, and are not + # subject to membership deactivation. + return self.organisations + def belongs_to(self, organisation_id: int) -> bool: return self.key.organisation_id == organisation_id diff --git a/api/audit/views.py b/api/audit/views.py index 1e65870382df..683cf4cf6ce0 100644 --- a/api/audit/views.py +++ b/api/audit/views.py @@ -95,6 +95,7 @@ def _get_base_filters(self) -> Q: return Q( project__organisation__userorganisation__user=self.request.user, project__organisation__userorganisation__role=OrganisationRole.ADMIN, + project__organisation__userorganisation__is_active=True, ) def _get_organisation(self) -> Organisation | None: @@ -109,9 +110,7 @@ def _get_organisation(self) -> Organisation | None: Since we're applying the base filters to the query set """ return ( # type: ignore[no-any-return] - self.request.user.organisations.filter( # type: ignore[union-attr] - userorganisation__role=OrganisationRole.ADMIN - ) + self.request.user.get_admin_organisations() # type: ignore[union-attr] .select_related("subscription", "subscription_information_cache") .first() ) diff --git a/api/organisations/invites/views.py b/api/organisations/invites/views.py index 0591d0390c8f..ccfbb3f32144 100644 --- a/api/organisations/invites/views.py +++ b/api/organisations/invites/views.py @@ -101,7 +101,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] raise SubscriptionDoesNotSupportSeatUpgrade() return InviteLink.objects.filter( - organisation__in=user.organisations.all() # type: ignore[union-attr] + organisation__in=user.get_active_organisations() # type: ignore[union-attr] ).filter(organisation__pk=organisation_pk) def perform_create(self, serializer): # type: ignore[no-untyped-def] @@ -144,9 +144,9 @@ def get_queryset(self): # type: ignore[no-untyped-def] organisation_pk = self.kwargs.get("organisation_pk") user = self.request.user - return Invite.objects.filter(organisation__in=user.organisations.all()).filter( # type: ignore[misc,union-attr] # noqa: E501 - organisation__id=organisation_pk - ) + return Invite.objects.filter( # type: ignore[misc] + organisation__in=user.get_active_organisations() # type: ignore[union-attr] + ).filter(organisation__id=organisation_pk) def get_serializer_context(self) -> dict[str, Any]: context = super().get_serializer_context() diff --git a/api/organisations/migrations/0061_add_user_organisation_is_active.py b/api/organisations/migrations/0061_add_user_organisation_is_active.py new file mode 100644 index 000000000000..8a29e9644100 --- /dev/null +++ b/api/organisations/migrations/0061_add_user_organisation_is_active.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.17 on 2026-08-25 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organisations", "0060_add_targeting_key"), + ] + + operations = [ + migrations.AddField( + model_name="userorganisation", + name="is_active", + field=models.BooleanField( + default=True, + help_text="Inactive members can still log in, but cannot access the organisation, and do not count towards its seat limit.", + ), + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index d02d1696b892..62eee07e3383 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -109,7 +109,7 @@ def get_unique_slug(self): # type: ignore[no-untyped-def] @property def num_seats(self) -> int: - return self.users.count() + return self.userorganisation_set.filter(is_active=True).count() def has_paid_subscription(self) -> bool: # Includes subscriptions that are canceled. @@ -231,6 +231,13 @@ class UserOrganisation(LifecycleModelMixin, models.Model): # type: ignore[misc] organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) date_joined = models.DateTimeField(auto_now_add=True) role = models.CharField(max_length=50, choices=OrganisationRole.choices) + is_active = models.BooleanField( + default=True, + help_text=( + "Inactive members can still log in, but cannot access the " + "organisation, and do not count towards its seat limit." + ), + ) class Meta: unique_together = ( diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index e2bf21729266..4e31128c419a 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -98,7 +98,8 @@ class UserOrganisationSerializer(serializers.ModelSerializer): # type: ignore[t class Meta: model = UserOrganisation - fields = ("role", "organisation") + fields = ("role", "organisation", "is_active") + read_only_fields = ("is_active",) class InviteSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg] diff --git a/api/organisations/task_helpers.py b/api/organisations/task_helpers.py index 0062633322dc..ac0b675f2b5e 100644 --- a/api/organisations/task_helpers.py +++ b/api/organisations/task_helpers.py @@ -27,6 +27,7 @@ def send_api_flags_blocked_notification(organisation: Organisation) -> None: recipient_list = FFAdminUser.objects.filter( userorganisation__organisation=organisation, + userorganisation__is_active=True, ) url = get_current_site_url() @@ -61,6 +62,7 @@ def _send_api_usage_notification( recipient_list = FFAdminUser.objects.filter( userorganisation__organisation=organisation, + userorganisation__is_active=True, ) if matched_threshold < 100: diff --git a/api/organisations/views.py b/api/organisations/views.py index 265d75395fb3..ee6068272a8d 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -105,7 +105,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): return Organisation.objects.none() - return self.request.user.organisations.all() # type: ignore[union-attr] + return self.request.user.get_active_organisations() # type: ignore[union-attr] def get_throttles(self): # type: ignore[no-untyped-def] if self.action == "invite": diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index ad1ab41cb86f..384f7fcfbd35 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -21,11 +21,29 @@ from users.models import FFAdminUser +def get_active_membership_filter(user: "FFAdminUser", prefix: str = "") -> Q: + """ + Build a filter matching objects related to an organisation that `user` is an + active member of. + + Deactivated memberships (`UserOrganisation.is_active=False`) are excluded, so + this must be used in place of traversing the `Organisation.users` M2M. + + `prefix` is the query path to the organisation, e.g. `"project__organisation__"`. + """ + return Q( + **{ + f"{prefix}userorganisation__user": user, + f"{prefix}userorganisation__is_active": True, + } + ) + + def is_user_organisation_admin( user: "FFAdminUser", organisation: Union[Organisation, int] ) -> bool: user_organisation = user.get_user_organisation(organisation) - if user_organisation is not None: + if user_organisation is not None and user_organisation.is_active: set_span_attribute("organisation.id", user_organisation.organisation_id) return user_organisation.role == OrganisationRole.ADMIN.name return False @@ -93,6 +111,7 @@ def get_permitted_projects_for_user( admin_organisations_filter = Q( organisation__userorganisation__user=user, organisation__userorganisation__role=OrganisationRole.ADMIN.name, + organisation__userorganisation__is_active=True, ) project_ids_from_admin_organisations = Project.objects.filter( admin_organisations_filter @@ -104,7 +123,7 @@ def get_permitted_projects_for_user( queryset = Project.objects.filter(id__in=project_ids) # Final check to ensure that the user is a member of the organisation - queryset = queryset.filter(organisation__users=user) + queryset = queryset.filter(get_active_membership_filter(user, "organisation__")) return queryset @@ -166,7 +185,9 @@ def get_permitted_environments_for_user( queryset = queryset.prefetch_related("metadata") # Final check to ensure the user is a member of the organisation - queryset = queryset.filter(project__organisation__users=user) + queryset = queryset.filter( + get_active_membership_filter(user, "project__organisation__") + ) # Description is defered due to Oracle support where a # query can't have a where clause if description is in @@ -214,7 +235,9 @@ def user_has_organisation_permission( return True # Check: verify user belongs to the organisation - if not Organisation.objects.filter(id=organisation.id, users=user).exists(): + if not Organisation.objects.filter( + get_active_membership_filter(user) & Q(id=organisation.id) + ).exists(): return False # NOTE: since we store organisation admin slightly differently @@ -274,11 +297,14 @@ def _is_user_object_admin( # Check: verify user belongs to the organisation that owns this object if model_class is Project: - if not Project.objects.filter(id=object_id, organisation__users=user).exists(): + if not Project.objects.filter( + get_active_membership_filter(user, "organisation__") & Q(id=object_id) + ).exists(): return False elif model_class is Environment: if not Environment.objects.filter( - id=object_id, project__organisation__users=user + get_active_membership_filter(user, "project__organisation__") + & Q(id=object_id) ).exists(): return False else: # pragma: no cover diff --git a/api/sales_dashboard/views.py b/api/sales_dashboard/views.py index 9327704931dd..19cbc532c9af 100644 --- a/api/sales_dashboard/views.py +++ b/api/sales_dashboard/views.py @@ -89,7 +89,9 @@ def get_queryset(self): # type: ignore[no-untyped-def] ), num_users=Coalesce( Subquery( - UserOrganisation.objects.filter(organisation_id=OuterRef("pk")) + UserOrganisation.objects.filter( + organisation_id=OuterRef("pk"), is_active=True + ) .values("organisation_id") .annotate(count=Count("user_id", distinct=True)) .values("count")[:1] diff --git a/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py new file mode 100644 index 000000000000..434105e0c334 --- /dev/null +++ b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py @@ -0,0 +1,245 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from organisations.models import ( + Organisation, + OrganisationRole, + UserOrganisation, +) +from projects.models import Project +from tests.types import WithOrganisationPermissionsCallable +from users.models import FFAdminUser + + +@pytest.fixture() +def deactivated_staff_user( + organisation: Organisation, staff_user: FFAdminUser +) -> FFAdminUser: + staff_user.set_organisation_membership_active(organisation, is_active=False) + return staff_user + + +def test_user_organisation__default__is_active( + organisation: Organisation, staff_user: FFAdminUser +) -> None: + # Given + # An organisation with a freshly added member. + + # When + user_organisation = UserOrganisation.objects.get( + user=staff_user, organisation=organisation + ) + + # Then + assert user_organisation.is_active is True + + +def test_num_seats__deactivated_membership__is_not_counted( + organisation: Organisation, + admin_user: FFAdminUser, + deactivated_staff_user: FFAdminUser, +) -> None: + # Given + # The organisation fixture has two members, one of which is now deactivated. + + # When + num_seats = organisation.num_seats + + # Then + assert num_seats == 1 + + +def test_over_plan_seats_limit__deactivated_membership__is_not_counted( + organisation: Organisation, deactivated_staff_user: FFAdminUser +) -> None: + # Given + organisation.subscription.max_seats = 1 + organisation.subscription.save() + + # When + over_plan_seats_limit = organisation.over_plan_seats_limit() + + # Then + assert over_plan_seats_limit is False + + +@pytest.mark.saas_mode +def test_set_organisation_membership_active__reactivate_over_seat_limit__is_allowed( + organisation: Organisation, + deactivated_staff_user: FFAdminUser, +) -> None: + # Given + # Reactivation is driven by an external identity provider over SCIM, so it + # deliberately goes over the seat limit rather than failing the call. + organisation.subscription.max_seats = 1 + organisation.subscription.save() + + # When + deactivated_staff_user.set_organisation_membership_active( + organisation, is_active=True + ) + + # Then + assert deactivated_staff_user.belongs_to(organisation.id) is True + assert organisation.num_seats == 2 + assert organisation.over_plan_seats_limit() is True + + +def test_set_organisation_membership_active__reactivate__restores_access( + organisation: Organisation, deactivated_staff_user: FFAdminUser +) -> None: + # Given + assert deactivated_staff_user.belongs_to(organisation.id) is False + + # When + deactivated_staff_user.set_organisation_membership_active( + organisation, is_active=True + ) + + # Then + assert deactivated_staff_user.belongs_to(organisation.id) is True + assert organisation.num_seats == 2 + + +def test_belongs_to__deactivated_membership__returns_false( + organisation: Organisation, deactivated_staff_user: FFAdminUser +) -> None: + # Given + # A user whose membership of the organisation has been deactivated. + + # When + belongs_to = deactivated_staff_user.belongs_to(organisation.id) + + # Then + assert belongs_to is False + + +def test_is_organisation_admin__deactivated_membership__returns_false( + organisation: Organisation, admin_user: FFAdminUser +) -> None: + # Given + assert admin_user.is_organisation_admin(organisation) is True + + # When + admin_user.set_organisation_membership_active(organisation, is_active=False) + + # Then + assert admin_user.is_organisation_admin(organisation) is False + assert list(admin_user.get_admin_organisations()) == [] + + +def test_get_active_organisations__deactivated_membership__is_excluded( + organisation: Organisation, deactivated_staff_user: FFAdminUser +) -> None: + # Given + # A user whose membership of the organisation has been deactivated. + + # When + active_organisations = deactivated_staff_user.get_active_organisations() + + # Then + assert list(active_organisations) == [] + # The membership itself is retained. + assert list(deactivated_staff_user.organisations.all()) == [organisation] + + +def test_list_organisations__deactivated_membership__is_excluded( + staff_client: APIClient, + organisation: Organisation, + deactivated_staff_user: FFAdminUser, +) -> None: + # Given + # A user whose membership of their only organisation has been deactivated. + + # When + response = staff_client.get(reverse("api-v1:organisations:organisation-list")) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [] + + +def test_retrieve_organisation__deactivated_membership__returns_403( + staff_client: APIClient, + organisation: Organisation, + deactivated_staff_user: FFAdminUser, +) -> None: + # Given + # A user whose membership of the organisation has been deactivated. + + # When + response = staff_client.get( + reverse("api-v1:organisations:organisation-detail", args=[organisation.id]) + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_permitted_projects__deactivated_membership__returns_nothing( + organisation: Organisation, + project: Project, + admin_user: FFAdminUser, +) -> None: + # Given + assert list(admin_user.get_permitted_projects("VIEW_PROJECT")) == [project] + + # When + admin_user.set_organisation_membership_active(organisation, is_active=False) + + # Then + assert list(admin_user.get_permitted_projects("VIEW_PROJECT")) == [] + + +def test_has_organisation_permission__deactivated_membership__returns_false( + organisation: Organisation, + staff_user: FFAdminUser, + with_organisation_permissions: WithOrganisationPermissionsCallable, +) -> None: + # Given + with_organisation_permissions(["CREATE_PROJECT"], None) + assert staff_user.has_organisation_permission(organisation, "CREATE_PROJECT") + + # When + staff_user.set_organisation_membership_active(organisation, is_active=False) + + # Then + assert ( + staff_user.has_organisation_permission(organisation, "CREATE_PROJECT") is False + ) + + +def test_login__deactivated_membership__succeeds( + api_client: APIClient, + organisation: Organisation, + deactivated_staff_user: FFAdminUser, +) -> None: + # Given + password = FFAdminUser.objects.make_random_password() + deactivated_staff_user.set_password(password) # type: ignore[no-untyped-call] + deactivated_staff_user.save() + + # When + response = api_client.post( + reverse("api-v1:custom_auth:custom-mfa-authtoken-login"), + data={"email": deactivated_staff_user.email, "password": password}, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["key"] + + +def test_add_organisation__new_membership__is_active( + organisation: Organisation, +) -> None: + # Given + user = FFAdminUser.objects.create(email="new@example.com") + + # When + user.add_organisation(organisation, role=OrganisationRole.USER) + + # Then + assert user.belongs_to(organisation.id) is True diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index e95e622d62dc..c65e62ab780c 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -73,7 +73,7 @@ def test_get_admin_organisations__user_with_mixed_roles__returns_only_admin_orgs admin_user.add_organisation(non_admin_organisation, OrganisationRole.USER) # When - admin_orgs = admin_user.get_admin_organisations() # type: ignore[no-untyped-call] + admin_orgs = admin_user.get_admin_organisations() # Then assert organisation in admin_orgs diff --git a/api/users/abc.py b/api/users/abc.py index 1c980199c0d7..f463a7842668 100644 --- a/api/users/abc.py +++ b/api/users/abc.py @@ -17,6 +17,13 @@ def is_authenticated(self) -> bool: def belongs_to(self, organisation_id: int) -> bool: raise NotImplementedError() + @abstractmethod + def get_active_organisations(self) -> QuerySet[Organisation]: + """ + Return the organisations this actor is able to access. + """ + raise NotImplementedError() + @abstractmethod def is_project_admin(self, project: "Project") -> bool: raise NotImplementedError() diff --git a/api/users/models.py b/api/users/models.py index d1dca609bbc7..67e1412d2251 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -254,12 +254,44 @@ def join_organisation_from_invite(self, invite: "AbstractBaseInviteModel"): # t def is_organisation_admin(self, organisation: typing.Union["Organisation", int]): # type: ignore[no-untyped-def] return is_user_organisation_admin(self, organisation) - def get_admin_organisations(self): # type: ignore[no-untyped-def] - return Organisation.objects.filter( + def get_admin_organisations(self) -> QuerySet[Organisation]: + # NOTE: the lookups must stay in a single `filter()` call so that they + # apply to the same `UserOrganisation` row. + return Organisation.objects.filter( # type: ignore[no-any-return] userorganisation__user=self, userorganisation__role=OrganisationRole.ADMIN.name, + userorganisation__is_active=True, ) + def get_active_organisations(self) -> QuerySet[Organisation]: + return Organisation.objects.filter( # type: ignore[no-any-return] + userorganisation__user=self, + userorganisation__is_active=True, + ) + + def set_organisation_membership_active( + self, organisation: Organisation, is_active: bool + ) -> None: + """ + Activate or deactivate this user's membership of `organisation`. + + Deactivated members keep their roles, permissions and group memberships, + but lose access to the organisation and free up their seat. + + Reactivation deliberately does not enforce the plan's seat limit: this is + driven by an external identity provider over SCIM, where failing the call + would leave the provider and Flagsmith out of sync. Going over the limit + is handled by the usual seat overage billing instead. + """ + user_organisation = UserOrganisation.objects.get( + user=self, organisation=organisation + ) + if user_organisation.is_active == is_active: + return + + user_organisation.is_active = is_active + user_organisation.save() + def add_organisation( self, organisation: Organisation, role: OrganisationRole = OrganisationRole.USER ) -> None: @@ -370,7 +402,7 @@ def _get_admin_user_emails(): # type: ignore[no-untyped-def] def belongs_to(self, organisation_id: int) -> bool: return self.userorganisation_set.filter( - organisation_id=organisation_id + organisation_id=organisation_id, is_active=True ).exists() def is_environment_admin( diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 8c508716727e..2891d8eece39 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -2,7 +2,7 @@ ### `api_usage.notification.evaluated` Logged at `info` from: - - `api/organisations/task_helpers.py:153` + - `api/organisations/task_helpers.py:155` Attributes: - `allowed_api_calls` @@ -16,7 +16,7 @@ Attributes: ### `api_usage.notification.missing_billing_starts_at` Logged at `error` from: - - `api/organisations/task_helpers.py:118` + - `api/organisations/task_helpers.py:120` Attributes: - `organisation.id` @@ -24,7 +24,7 @@ Attributes: ### `api_usage.notification.sent` Logged at `info` from: - - `api/organisations/task_helpers.py:176` + - `api/organisations/task_helpers.py:178` Attributes: - `matched_threshold` From e169e20ed7d98bda7844e5d4818f21565f551cc1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 25 Aug 2026 20:43:37 +0100 Subject: [PATCH 2/7] docs(scim): document deactivated user membership Ref: #8368 --- .../access-control/scim.md | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/docs/docs/administration-and-security/access-control/scim.md b/docs/docs/administration-and-security/access-control/scim.md index 5fe4fba2fe06..e688b14cf637 100644 --- a/docs/docs/administration-and-security/access-control/scim.md +++ b/docs/docs/administration-and-security/access-control/scim.md @@ -18,7 +18,7 @@ With SCIM, you can: - Create Flagsmith users ahead of their first login, so they already have the right group memberships and permissions waiting for them. -- Remove users from your Flagsmith organisation when they are deprovisioned in your identity provider. +- Deactivate or remove users from your Flagsmith organisation when they are deprovisioned in your identity provider. - Sync group membership so that adding or removing a user from a group in your identity provider is reflected in Flagsmith automatically. @@ -30,6 +30,7 @@ provisioning (which users and groups exist in Flagsmith, and who belongs to what Flagsmith's SCIM 2.0 API supports: - Creating users. +- Deactivating and reactivating users through the `active` attribute. See [Deactivating users](#deactivating-users). - Deleting users. See [User lifecycle](#user-lifecycle) for what is and is not removed. - Pushing groups: create and update [permission groups](/administration-and-security/access-control/rbac#groups) and their membership, and delete groups. @@ -37,7 +38,6 @@ Flagsmith's SCIM 2.0 API supports: Flagsmith does not support: -- Deactivating users through the `active` attribute. Use DELETE requests to deprovision users instead. - Profile sourcing, `/Me`, `/Bulk`, sorting, and ETag concurrency control. ## Prerequisites @@ -69,9 +69,28 @@ When your identity provider deprovisions a user by sending a DELETE request: [permission groups](/administration-and-security/access-control/rbac#groups) in that organisation. 2. The user's data (audit log entries, change request history) is preserved. -:::caution +### Deactivating users + +Deactivation is the reversible alternative to deletion. Setting the SCIM `active` attribute to `false` — through either +a PUT or a PATCH request — deactivates the user's membership of the organisation: + +1. The user can still log in to Flagsmith, but cannot access the organisation, and no longer sees it in their + organisation picker. +2. The membership no longer counts towards your plan's seat limit. +3. Their role, project and environment permissions, and + [permission group](/administration-and-security/access-control/rbac#groups) memberships are all retained. + +Setting `active` back to `true` reactivates the membership and restores access exactly as it was. A reactivated member +takes up a seat again. Reactivation always succeeds, even when that takes you over your plan's seat limit — your +identity provider stays the source of truth, and the extra seats are billed as an overage. + +Deactivated members remain visible over SCIM: a GET on the user returns `200` with `"active": false`, and they appear in +`/Users` list responses. -Deprovisioning is supported through DELETE requests only. Flagsmith does not act on the SCIM `active` attribute. +:::tip + +Prefer deactivation over deletion when a user might return, or when you want to preserve their permissions while they +are on leave. Use DELETE when you want the membership and its permissions gone for good. ::: @@ -79,14 +98,15 @@ Deprovisioning is supported through DELETE requests only. Flagsmith does not act Flagsmith reads the following attributes from SCIM user requests: -| Attribute | Required | Maps to | -| ----------------- | -------- | ------------- | -| `userName` | Yes | Email address | -| `name.givenName` | No | First name | -| `name.familyName` | No | Last name | +| Attribute | Required | Maps to | +| ----------------- | -------- | ------------------------------ | +| `userName` | Yes | Email address | +| `name.givenName` | No | First name | +| `name.familyName` | No | Last name | +| `active` | No | Organisation membership status | All other attributes are ignored. If your identity provider lets you choose which attributes to send, sending only the -three above keeps your configuration simpler and avoids implying that Flagsmith stores data it does not. +four above keeps your configuration simpler and avoids implying that Flagsmith stores data it does not. ## Group lifecycle @@ -172,10 +192,14 @@ These steps are for the Flagsmith application from the Okta Integration Network 1. On the "Provisioning" tab, select "Integration" and click "Edit". Tick "Enable API integration" and paste your SCIM bearer token into **API Token**. 1. Click "Test API Credentials" to verify the connection, then save. -1. Select "To App" and click "Edit", then enable "Create Users" and "Update User Attributes". +1. Select "To App" and click "Edit", then enable "Create Users", "Update User Attributes" and "Deactivate Users". + +To deactivate a user, deactivate them in Okta or unassign them from the application. Okta sends `active: false` and +Flagsmith deactivates their membership, freeing their seat while preserving their permissions. Reactivating them in Okta +restores their access. -To deprovision a user, unassign them from the application in Okta. Okta sends a DELETE request and Flagsmith removes the -user from your organisation. +To remove a user entirely, delete them from Okta. Okta sends a DELETE request and Flagsmith removes the user from your +organisation along with their permissions. To sync groups, use the "Push Groups" tab to select the Okta groups you want to push to Flagsmith. @@ -232,7 +256,14 @@ endpoints as defined by the SCIM 2.0 specification. ### Deprovisioned users still appear in the organisation -- Check that your identity provider is sending a DELETE request when deprovisioning a user. Flagsmith does not act on - the `active` attribute, so a PATCH request setting `active` to `false` does not remove the user — it returns a 501 - response. In Okta, this means removing the user from the application rather than deactivating them. -- Some identity providers require explicit configuration to send deprovisioning events. +- Deactivated users are intentionally retained. They keep their `active: false` membership so they can be reactivated + later, and they do not count towards your seat limit. If you want the membership gone entirely, your identity provider + must send a DELETE request. +- Check that your identity provider is configured to send deprovisioning events at all — in Okta, for example, + "Deactivate Users" must be enabled under "Provisioning to App". + +### Reactivated users took the organisation over its seat limit + +- Reactivating a deactivated member takes up a seat again, and Flagsmith does not block reactivation to keep you within + your plan's limit — doing so would leave your identity provider and Flagsmith out of sync. The additional seats are + billed as an overage. Deactivate or remove members in your identity provider to bring the count back down. From 616167247efc951d8273cce9964a47bd2ddd8799 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 25 Aug 2026 19:47:17 +0000 Subject: [PATCH 3/7] chore: Update documentation artefacts --- openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 1620cdd9ac9c..b3e95d9543a5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -28542,6 +28542,10 @@ components: allOf: - $ref: '#/components/schemas/OrganisationSerializerBasic' readOnly: true + is_active: + description: 'Inactive members can still log in, but cannot access the organisation, and do not count towards its seat limit.' + type: boolean + readOnly: true required: - role UserOrganisationPermissionList: From cb13a4744c552d7a449d3fdc0b1e1398aa830d53 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 25 Aug 2026 20:52:51 +0100 Subject: [PATCH 4/7] docs(scim): fold deactivation into the user lifecycle section Ref: #8368 --- .../access-control/scim.md | 34 ++++--------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/docs/docs/administration-and-security/access-control/scim.md b/docs/docs/administration-and-security/access-control/scim.md index e688b14cf637..a3484ed54dc5 100644 --- a/docs/docs/administration-and-security/access-control/scim.md +++ b/docs/docs/administration-and-security/access-control/scim.md @@ -30,7 +30,8 @@ provisioning (which users and groups exist in Flagsmith, and who belongs to what Flagsmith's SCIM 2.0 API supports: - Creating users. -- Deactivating and reactivating users through the `active` attribute. See [Deactivating users](#deactivating-users). +- Deactivating and reactivating users through the `active` attribute. See [User lifecycle](#user-lifecycle) for what is + and is not retained. - Deleting users. See [User lifecycle](#user-lifecycle) for what is and is not removed. - Pushing groups: create and update [permission groups](/administration-and-security/access-control/rbac#groups) and their membership, and delete groups. @@ -69,30 +70,14 @@ When your identity provider deprovisions a user by sending a DELETE request: [permission groups](/administration-and-security/access-control/rbac#groups) in that organisation. 2. The user's data (audit log entries, change request history) is preserved. -### Deactivating users - -Deactivation is the reversible alternative to deletion. Setting the SCIM `active` attribute to `false` — through either -a PUT or a PATCH request — deactivates the user's membership of the organisation: +When your identity provider deprovisions a user by sending a PUT or a PATCH request with `active: false`: 1. The user can still log in to Flagsmith, but cannot access the organisation, and no longer sees it in their organisation picker. 2. The membership no longer counts towards your plan's seat limit. 3. Their role, project and environment permissions, and [permission group](/administration-and-security/access-control/rbac#groups) memberships are all retained. - -Setting `active` back to `true` reactivates the membership and restores access exactly as it was. A reactivated member -takes up a seat again. Reactivation always succeeds, even when that takes you over your plan's seat limit — your -identity provider stays the source of truth, and the extra seats are billed as an overage. - -Deactivated members remain visible over SCIM: a GET on the user returns `200` with `"active": false`, and they appear in -`/Users` list responses. - -:::tip - -Prefer deactivation over deletion when a user might return, or when you want to preserve their permissions while they -are on leave. Use DELETE when you want the membership and its permissions gone for good. - -::: +4. The membership can be reactivated by sending a PUT or a PATCH request with `active: true`. ### User attributes @@ -194,9 +179,8 @@ These steps are for the Flagsmith application from the Okta Integration Network 1. Click "Test API Credentials" to verify the connection, then save. 1. Select "To App" and click "Edit", then enable "Create Users", "Update User Attributes" and "Deactivate Users". -To deactivate a user, deactivate them in Okta or unassign them from the application. Okta sends `active: false` and -Flagsmith deactivates their membership, freeing their seat while preserving their permissions. Reactivating them in Okta -restores their access. +To deactivate a user, deactivate them in Okta or unassign them from the application. Reactivating them in Okta restores +their access. To remove a user entirely, delete them from Okta. Okta sends a DELETE request and Flagsmith removes the user from your organisation along with their permissions. @@ -261,9 +245,3 @@ endpoints as defined by the SCIM 2.0 specification. must send a DELETE request. - Check that your identity provider is configured to send deprovisioning events at all — in Okta, for example, "Deactivate Users" must be enabled under "Provisioning to App". - -### Reactivated users took the organisation over its seat limit - -- Reactivating a deactivated member takes up a seat again, and Flagsmith does not block reactivation to keep you within - your plan's limit — doing so would leave your identity provider and Flagsmith out of sync. The additional seats are - billed as an overage. Deactivate or remove members in your identity provider to bring the count back down. From 70993ec2016d866e385e196523fb73a30b60f9f2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 25 Aug 2026 21:10:31 +0100 Subject: [PATCH 5/7] fix(organisations): retain an active admin when cancelling users `cancel_users` picked the earliest-joined admin without regard to `is_active`. A deactivated admin could therefore be retained while every active membership was deleted, leaving the organisation with no user able to access it and no way to undo it. Prefer the earliest active admin, fall back to the earliest active member, and no-op when no seat is in use \u2014 which also removes the latent `AttributeError` when the organisation has no admin at all. Ref: #8368 --- api/organisations/models.py | 27 +++++--- ...it_organisations_deactivated_membership.py | 64 +++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 62eee07e3383..f058f09a3ff2 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -209,21 +209,30 @@ def rebuild_environments(self): # type: ignore[no-untyped-def] ).values_list("id", flat=True): rebuild_environment_document.delay(args=(environment_id,)) - def cancel_users(self): # type: ignore[no-untyped-def] + def cancel_users(self) -> None: + """ + Reduce the organisation to the single seat the free plan allows. + + The retained member must hold an active membership, otherwise the + organisation would be left with nobody able to access it. + """ + active_memberships = UserOrganisation.objects.filter( + organisation=self, + is_active=True, + ) remaining_seat_holder = ( - UserOrganisation.objects.filter( - organisation=self, - role=OrganisationRole.ADMIN, - ) + active_memberships.filter(role=OrganisationRole.ADMIN) .order_by("date_joined") .first() + or active_memberships.order_by("date_joined").first() ) + if remaining_seat_holder is None: + # No seat is in use, so there is nothing to cancel down to. + return UserOrganisation.objects.filter( organisation=self, - ).exclude( - id=remaining_seat_holder.id # type: ignore[union-attr] - ).delete() + ).exclude(id=remaining_seat_holder.id).delete() class UserOrganisation(LifecycleModelMixin, models.Model): # type: ignore[misc] @@ -389,7 +398,7 @@ def prepare_for_cancel( # type: ignore[no-untyped-def] if cancellation_date <= timezone.now(): # Since the date is immediate, wipe data right away. - self.organisation.cancel_users() # type: ignore[no-untyped-call] + self.organisation.cancel_users() self.save_as_free_subscription() # type: ignore[no-untyped-call] return diff --git a/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py index 434105e0c334..50c223c99d38 100644 --- a/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py +++ b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py @@ -1,5 +1,8 @@ +from datetime import timedelta + import pytest from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -243,3 +246,64 @@ def test_add_organisation__new_membership__is_active( # Then assert user.belongs_to(organisation.id) is True + + +def test_cancel_users__deactivated_earliest_admin__retains_an_active_admin( + organisation: Organisation, + admin_user: FFAdminUser, + staff_user: FFAdminUser, +) -> None: + # Given + # The earliest-joined admin has left and been deactivated, so retaining + # them would leave nobody able to access the organisation. + founding_admin = FFAdminUser.objects.create(email="founder@example.com") + founding_admin.add_organisation(organisation, role=OrganisationRole.ADMIN) + UserOrganisation.objects.filter( + user=founding_admin, organisation=organisation + ).update(date_joined=timezone.now() - timedelta(days=365)) + founding_admin.set_organisation_membership_active(organisation, is_active=False) + + # When + organisation.cancel_users() + + # Then + assert admin_user.belongs_to(organisation.id) is True + assert organisation.num_seats == 1 + assert not UserOrganisation.objects.filter( + user=founding_admin, organisation=organisation + ).exists() + + +def test_cancel_users__no_active_admin__retains_earliest_active_member( + organisation: Organisation, + admin_user: FFAdminUser, + staff_user: FFAdminUser, +) -> None: + # Given + # Every admin is deactivated, leaving only a regular member holding a seat. + admin_user.set_organisation_membership_active(organisation, is_active=False) + + # When + organisation.cancel_users() + + # Then + assert staff_user.belongs_to(organisation.id) is True + assert organisation.num_seats == 1 + + +def test_cancel_users__no_active_memberships__is_a_noop( + organisation: Organisation, + admin_user: FFAdminUser, + staff_user: FFAdminUser, +) -> None: + # Given + # Nothing is holding a seat, so there is nothing to cancel down to. + admin_user.set_organisation_membership_active(organisation, is_active=False) + staff_user.set_organisation_membership_active(organisation, is_active=False) + + # When + organisation.cancel_users() + + # Then + assert organisation.num_seats == 0 + assert UserOrganisation.objects.filter(organisation=organisation).count() == 2 From 20bff2770fbf9a0e226bdba3f7583f5bdc898eb0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 25 Aug 2026 21:33:32 +0100 Subject: [PATCH 6/7] test(organisations): cover the no-op membership activation branch The early return in `set_organisation_membership_active` lost its test when the seat-limit check was removed, leaving the branch uncovered. Ref: #8368 --- ...it_organisations_deactivated_membership.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py index 50c223c99d38..a6c8fef112b6 100644 --- a/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py +++ b/api/tests/unit/organisations/test_unit_organisations_deactivated_membership.py @@ -3,6 +3,7 @@ import pytest from django.urls import reverse from django.utils import timezone +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient @@ -106,6 +107,26 @@ def test_set_organisation_membership_active__reactivate__restores_access( assert organisation.num_seats == 2 +@pytest.mark.parametrize("is_active", [True, False]) +def test_set_organisation_membership_active__no_change__does_not_write( + organisation: Organisation, + staff_user: FFAdminUser, + mocker: MockerFixture, + is_active: bool, +) -> None: + # Given + staff_user.set_organisation_membership_active(organisation, is_active=is_active) + mocked_save = mocker.patch.object(UserOrganisation, "save") + + # When + # The membership is already in the requested state. + staff_user.set_organisation_membership_active(organisation, is_active=is_active) + + # Then + mocked_save.assert_not_called() + assert staff_user.belongs_to(organisation.id) is is_active + + def test_belongs_to__deactivated_membership__returns_false( organisation: Organisation, deactivated_staff_user: FFAdminUser ) -> None: From 38dda0a3e6661418792e54eba05b4e7860c09db5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 26 Aug 2026 09:17:13 +0100 Subject: [PATCH 7/7] docs: drop the incorrect seat-overage claim on reactivation Nothing bills for the extra seat: `add_single_seat` is only called from the invite flow, and the codebase's overage billing covers API calls, not seats. Ref: #8368 --- api/users/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/users/models.py b/api/users/models.py index 67e1412d2251..78344f754d61 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -280,8 +280,7 @@ def set_organisation_membership_active( Reactivation deliberately does not enforce the plan's seat limit: this is driven by an external identity provider over SCIM, where failing the call - would leave the provider and Flagsmith out of sync. Going over the limit - is handled by the usual seat overage billing instead. + would leave the provider and Flagsmith out of sync. """ user_organisation = UserOrganisation.objects.get( user=self, organisation=organisation