Skip to content

Commit c0b2a31

Browse files
feat(SCIM): Support deactivated user membership (#8370)
Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent 0be48ea commit c0b2a31

17 files changed

Lines changed: 505 additions & 52 deletions

File tree

api/api_keys/user.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def is_master_api_key_user(self) -> bool:
4242
def organisations(self) -> QuerySet[Organisation]:
4343
return Organisation.objects.filter(id=self.key.organisation_id) # type: ignore[no-any-return]
4444

45+
def get_active_organisations(self) -> QuerySet[Organisation]:
46+
# Master API keys are scoped to a single organisation, and are not
47+
# subject to membership deactivation.
48+
return self.organisations
49+
4550
def belongs_to(self, organisation_id: int) -> bool:
4651
return self.key.organisation_id == organisation_id
4752

api/audit/views.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def _get_base_filters(self) -> Q:
9595
return Q(
9696
project__organisation__userorganisation__user=self.request.user,
9797
project__organisation__userorganisation__role=OrganisationRole.ADMIN,
98+
project__organisation__userorganisation__is_active=True,
9899
)
99100

100101
def _get_organisation(self) -> Organisation | None:
@@ -109,9 +110,7 @@ def _get_organisation(self) -> Organisation | None:
109110
Since we're applying the base filters to the query set
110111
"""
111112
return ( # type: ignore[no-any-return]
112-
self.request.user.organisations.filter( # type: ignore[union-attr]
113-
userorganisation__role=OrganisationRole.ADMIN
114-
)
113+
self.request.user.get_admin_organisations() # type: ignore[union-attr]
115114
.select_related("subscription", "subscription_information_cache")
116115
.first()
117116
)

api/organisations/invites/views.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
101101
raise SubscriptionDoesNotSupportSeatUpgrade()
102102

103103
return InviteLink.objects.filter(
104-
organisation__in=user.organisations.all() # type: ignore[union-attr]
104+
organisation__in=user.get_active_organisations() # type: ignore[union-attr]
105105
).filter(organisation__pk=organisation_pk)
106106

107107
def perform_create(self, serializer): # type: ignore[no-untyped-def]
@@ -144,9 +144,9 @@ def get_queryset(self): # type: ignore[no-untyped-def]
144144
organisation_pk = self.kwargs.get("organisation_pk")
145145
user = self.request.user
146146

147-
return Invite.objects.filter(organisation__in=user.organisations.all()).filter( # type: ignore[misc,union-attr] # noqa: E501
148-
organisation__id=organisation_pk
149-
)
147+
return Invite.objects.filter( # type: ignore[misc]
148+
organisation__in=user.get_active_organisations() # type: ignore[union-attr]
149+
).filter(organisation__id=organisation_pk)
150150

151151
def get_serializer_context(self) -> dict[str, Any]:
152152
context = super().get_serializer_context()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.17 on 2026-08-25 18:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("organisations", "0060_add_targeting_key"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="userorganisation",
15+
name="is_active",
16+
field=models.BooleanField(
17+
default=True,
18+
help_text="Inactive members can still log in, but cannot access the organisation, and do not count towards its seat limit.",
19+
),
20+
),
21+
]

api/organisations/models.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def get_unique_slug(self): # type: ignore[no-untyped-def]
109109

110110
@property
111111
def num_seats(self) -> int:
112-
return self.users.count()
112+
return self.userorganisation_set.filter(is_active=True).count()
113113

114114
def has_paid_subscription(self) -> bool:
115115
# Includes subscriptions that are canceled.
@@ -209,28 +209,44 @@ def rebuild_environments(self): # type: ignore[no-untyped-def]
209209
).values_list("id", flat=True):
210210
rebuild_environment_document.delay(args=(environment_id,))
211211

212-
def cancel_users(self): # type: ignore[no-untyped-def]
212+
def cancel_users(self) -> None:
213+
"""
214+
Reduce the organisation to the single seat the free plan allows.
215+
216+
The retained member must hold an active membership, otherwise the
217+
organisation would be left with nobody able to access it.
218+
"""
219+
active_memberships = UserOrganisation.objects.filter(
220+
organisation=self,
221+
is_active=True,
222+
)
213223
remaining_seat_holder = (
214-
UserOrganisation.objects.filter(
215-
organisation=self,
216-
role=OrganisationRole.ADMIN,
217-
)
224+
active_memberships.filter(role=OrganisationRole.ADMIN)
218225
.order_by("date_joined")
219226
.first()
227+
or active_memberships.order_by("date_joined").first()
220228
)
229+
if remaining_seat_holder is None:
230+
# No seat is in use, so there is nothing to cancel down to.
231+
return
221232

222233
UserOrganisation.objects.filter(
223234
organisation=self,
224-
).exclude(
225-
id=remaining_seat_holder.id # type: ignore[union-attr]
226-
).delete()
235+
).exclude(id=remaining_seat_holder.id).delete()
227236

228237

229238
class UserOrganisation(LifecycleModelMixin, models.Model): # type: ignore[misc]
230239
user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
231240
organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE)
232241
date_joined = models.DateTimeField(auto_now_add=True)
233242
role = models.CharField(max_length=50, choices=OrganisationRole.choices)
243+
is_active = models.BooleanField(
244+
default=True,
245+
help_text=(
246+
"Inactive members can still log in, but cannot access the "
247+
"organisation, and do not count towards its seat limit."
248+
),
249+
)
234250

235251
class Meta:
236252
unique_together = (
@@ -382,7 +398,7 @@ def prepare_for_cancel( # type: ignore[no-untyped-def]
382398

383399
if cancellation_date <= timezone.now():
384400
# Since the date is immediate, wipe data right away.
385-
self.organisation.cancel_users() # type: ignore[no-untyped-call]
401+
self.organisation.cancel_users()
386402
self.save_as_free_subscription() # type: ignore[no-untyped-call]
387403
return
388404

api/organisations/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ class UserOrganisationSerializer(serializers.ModelSerializer): # type: ignore[t
9898

9999
class Meta:
100100
model = UserOrganisation
101-
fields = ("role", "organisation")
101+
fields = ("role", "organisation", "is_active")
102+
read_only_fields = ("is_active",)
102103

103104

104105
class InviteSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg]

api/organisations/task_helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
def send_api_flags_blocked_notification(organisation: Organisation) -> None:
2828
recipient_list = FFAdminUser.objects.filter(
2929
userorganisation__organisation=organisation,
30+
userorganisation__is_active=True,
3031
)
3132

3233
url = get_current_site_url()
@@ -61,6 +62,7 @@ def _send_api_usage_notification(
6162

6263
recipient_list = FFAdminUser.objects.filter(
6364
userorganisation__organisation=organisation,
65+
userorganisation__is_active=True,
6466
)
6567

6668
if matched_threshold < 100:

api/organisations/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
105105
if getattr(self, "swagger_fake_view", False):
106106
return Organisation.objects.none()
107107

108-
return self.request.user.organisations.all() # type: ignore[union-attr]
108+
return self.request.user.get_active_organisations() # type: ignore[union-attr]
109109

110110
def get_throttles(self): # type: ignore[no-untyped-def]
111111
if self.action == "invite":

api/permissions/permission_service.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,29 @@
2121
from users.models import FFAdminUser
2222

2323

24+
def get_active_membership_filter(user: "FFAdminUser", prefix: str = "") -> Q:
25+
"""
26+
Build a filter matching objects related to an organisation that `user` is an
27+
active member of.
28+
29+
Deactivated memberships (`UserOrganisation.is_active=False`) are excluded, so
30+
this must be used in place of traversing the `Organisation.users` M2M.
31+
32+
`prefix` is the query path to the organisation, e.g. `"project__organisation__"`.
33+
"""
34+
return Q(
35+
**{
36+
f"{prefix}userorganisation__user": user,
37+
f"{prefix}userorganisation__is_active": True,
38+
}
39+
)
40+
41+
2442
def is_user_organisation_admin(
2543
user: "FFAdminUser", organisation: Union[Organisation, int]
2644
) -> bool:
2745
user_organisation = user.get_user_organisation(organisation)
28-
if user_organisation is not None:
46+
if user_organisation is not None and user_organisation.is_active:
2947
set_span_attribute("organisation.id", user_organisation.organisation_id)
3048
return user_organisation.role == OrganisationRole.ADMIN.name
3149
return False
@@ -93,6 +111,7 @@ def get_permitted_projects_for_user(
93111
admin_organisations_filter = Q(
94112
organisation__userorganisation__user=user,
95113
organisation__userorganisation__role=OrganisationRole.ADMIN.name,
114+
organisation__userorganisation__is_active=True,
96115
)
97116
project_ids_from_admin_organisations = Project.objects.filter(
98117
admin_organisations_filter
@@ -104,7 +123,7 @@ def get_permitted_projects_for_user(
104123
queryset = Project.objects.filter(id__in=project_ids)
105124

106125
# Final check to ensure that the user is a member of the organisation
107-
queryset = queryset.filter(organisation__users=user)
126+
queryset = queryset.filter(get_active_membership_filter(user, "organisation__"))
108127

109128
return queryset
110129

@@ -166,7 +185,9 @@ def get_permitted_environments_for_user(
166185
queryset = queryset.prefetch_related("metadata")
167186

168187
# Final check to ensure the user is a member of the organisation
169-
queryset = queryset.filter(project__organisation__users=user)
188+
queryset = queryset.filter(
189+
get_active_membership_filter(user, "project__organisation__")
190+
)
170191

171192
# Description is defered due to Oracle support where a
172193
# query can't have a where clause if description is in
@@ -214,7 +235,9 @@ def user_has_organisation_permission(
214235
return True
215236

216237
# Check: verify user belongs to the organisation
217-
if not Organisation.objects.filter(id=organisation.id, users=user).exists():
238+
if not Organisation.objects.filter(
239+
get_active_membership_filter(user) & Q(id=organisation.id)
240+
).exists():
218241
return False
219242

220243
# NOTE: since we store organisation admin slightly differently
@@ -274,11 +297,14 @@ def _is_user_object_admin(
274297

275298
# Check: verify user belongs to the organisation that owns this object
276299
if model_class is Project:
277-
if not Project.objects.filter(id=object_id, organisation__users=user).exists():
300+
if not Project.objects.filter(
301+
get_active_membership_filter(user, "organisation__") & Q(id=object_id)
302+
).exists():
278303
return False
279304
elif model_class is Environment:
280305
if not Environment.objects.filter(
281-
id=object_id, project__organisation__users=user
306+
get_active_membership_filter(user, "project__organisation__")
307+
& Q(id=object_id)
282308
).exists():
283309
return False
284310
else: # pragma: no cover

api/sales_dashboard/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ def get_queryset(self): # type: ignore[no-untyped-def]
8989
),
9090
num_users=Coalesce(
9191
Subquery(
92-
UserOrganisation.objects.filter(organisation_id=OuterRef("pk"))
92+
UserOrganisation.objects.filter(
93+
organisation_id=OuterRef("pk"), is_active=True
94+
)
9395
.values("organisation_id")
9496
.annotate(count=Count("user_id", distinct=True))
9597
.values("count")[:1]

0 commit comments

Comments
 (0)