Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/api_keys/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions api/audit/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
)
Expand Down
8 changes: 4 additions & 4 deletions api/organisations/invites/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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.",
),
),
]
36 changes: 26 additions & 10 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -209,28 +209,44 @@ 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()
Comment thread
matthewelwell marked this conversation as resolved.


class UserOrganisation(LifecycleModelMixin, models.Model): # type: ignore[misc]
user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
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."
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class Meta:
unique_together = (
Expand Down Expand Up @@ -382,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

Expand Down
3 changes: 2 additions & 1 deletion api/organisations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions api/organisations/task_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
38 changes: 32 additions & 6 deletions api/permissions/permission_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion api/sales_dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading