From 5c88a7d8e204353753c09a31916301bc6ed36074 Mon Sep 17 00:00:00 2001
From: IsmailofficialGithub
Date: Fri, 21 Aug 2026 19:05:04 +0500
Subject: [PATCH] fix(membership): block self-rejoin after admin removes a
member
Add access_revoked so public-project Join cannot reactivate admin-removed
memberships until an admin adds or invites again. Voluntary leave can still rejoin.
Fixes #9662
---
apps/api/plane/api/views/member.py | 1 +
apps/api/plane/app/views/project/invite.py | 33 ++++++++++++++++---
apps/api/plane/app/views/project/member.py | 16 +++++++--
apps/api/plane/app/views/workspace/invite.py | 4 ++-
apps/api/plane/app/views/workspace/member.py | 6 ++--
.../0123_projectmember_access_revoked.py | 20 +++++++++++
.../0124_workspacemember_access_revoked.py | 20 +++++++++++
...25_mark_inactive_members_access_revoked.py | 27 +++++++++++++++
apps/api/plane/db/models/project.py | 2 ++
apps/api/plane/db/models/workspace.py | 2 ++
.../unit/utils/test_project_access_revoked.py | 13 ++++++++
.../project/confirm-project-member-remove.tsx | 5 +--
.../components/project/join-project-modal.tsx | 15 +++++++--
.../layouts/auth-layout/project-wrapper.tsx | 15 ++++++++-
14 files changed, 164 insertions(+), 15 deletions(-)
create mode 100644 apps/api/plane/db/migrations/0123_projectmember_access_revoked.py
create mode 100644 apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py
create mode 100644 apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py
create mode 100644 apps/api/plane/tests/unit/utils/test_project_access_revoked.py
diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py
index 4c8a04d599c..3a81d668332 100644
--- a/apps/api/plane/api/views/member.py
+++ b/apps/api/plane/api/views/member.py
@@ -226,6 +226,7 @@ def patch(self, request, slug, project_id, pk):
def delete(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk)
project_member.is_active = False
+ project_member.access_revoked = True
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py
index 45c997596fd..9f6ca381e0f 100644
--- a/apps/api/plane/app/views/project/invite.py
+++ b/apps/api/plane/app/views/project/invite.py
@@ -154,9 +154,31 @@ def create(self, request, slug):
# network check above (GHSA-45hc-q4mw-jhxm).
validated_project_ids = [str(p.id) for p in projects]
- # If the user was already part of workspace
+ # Admin-removed members cannot self-join until an admin re-adds or invites them
+ revoked_project_ids = list(
+ ProjectMember.objects.filter(
+ workspace__slug=slug,
+ project_id__in=validated_project_ids,
+ member=request.user,
+ is_active=False,
+ access_revoked=True,
+ ).values_list("project_id", flat=True)
+ )
+ if revoked_project_ids:
+ return Response(
+ {
+ "error": "Your access to this project was revoked. Ask a project admin to add you again."
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # Reactivate only voluntary leavers (not admin-revoked)
_ = ProjectMember.objects.filter(
- workspace__slug=slug, project_id__in=validated_project_ids, member=request.user
+ workspace__slug=slug,
+ project_id__in=validated_project_ids,
+ member=request.user,
+ is_active=False,
+ access_revoked=False,
).update(is_active=True)
ProjectMember.objects.bulk_create(
@@ -167,6 +189,7 @@ def create(self, request, slug):
role=workspace_role,
workspace=workspace,
created_by=request.user,
+ access_revoked=False,
)
for project_id in validated_project_ids
],
@@ -251,7 +274,7 @@ def post(self, request, slug, project_id, pk):
# Check if the user was already a member of project then activate the user
project_member = ProjectMember.objects.filter(
- workspace_id=project_invite.workspace_id, member=user
+ workspace_id=project_invite.workspace_id, project_id=project_id, member=user
).first()
if project_member is None:
# Create a Project Member
@@ -259,10 +282,12 @@ def post(self, request, slug, project_id, pk):
project_id=project_id,
member=user,
role=project_invite.role,
+ access_revoked=False,
)
else:
project_member.is_active = True
- project_member.role = project_member.role
+ project_member.role = project_invite.role
+ project_member.access_revoked = False
project_member.save()
return Response(
diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py
index 973462182ab..1710c0ca8f9 100644
--- a/apps/api/plane/app/views/project/member.py
+++ b/apps/api/plane/app/views/project/member.py
@@ -89,10 +89,13 @@ def create(self, request, slug, project_id):
):
project_member.role = member_roles[str(project_member.member_id)]
project_member.is_active = True
+ project_member.access_revoked = False
bulk_project_members.append(project_member)
# Update the roles of the existing members
- ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100)
+ ProjectMember.objects.bulk_update(
+ bulk_project_members, ["is_active", "role", "access_revoked"], batch_size=100
+ )
# Get the minimum sort_order for each member in the workspace
member_sort_orders = (
@@ -280,10 +283,17 @@ def partial_update(self, request, slug, project_id, pk):
status=status.HTTP_403_FORBIDDEN,
)
+ was_active = project_member.is_active
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
+ if was_active and serializer.instance.is_active is False:
+ serializer.instance.access_revoked = True
+ serializer.instance.save(update_fields=["access_revoked", "updated_at"])
+ elif (not was_active) and serializer.instance.is_active is True:
+ serializer.instance.access_revoked = False
+ serializer.instance.save(update_fields=["access_revoked", "updated_at"])
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -317,6 +327,7 @@ def destroy(self, request, slug, project_id, pk):
)
project_member.is_active = False
+ project_member.access_revoked = True
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -343,8 +354,9 @@ def leave(self, request, slug, project_id):
},
status=status.HTTP_400_BAD_REQUEST,
)
- # Deactivate the user
+ # Deactivate the user (voluntary leave — public self-join still allowed)
project_member.is_active = False
+ project_member.access_revoked = False
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py
index 0c61bce4419..905d351a222 100644
--- a/apps/api/plane/app/views/workspace/invite.py
+++ b/apps/api/plane/app/views/workspace/invite.py
@@ -206,6 +206,7 @@ def post(self, request, slug, pk):
).first()
if workspace_member is not None:
workspace_member.is_active = True
+ workspace_member.access_revoked = False
workspace_member.role = workspace_invite.role
workspace_member.save()
else:
@@ -214,6 +215,7 @@ def post(self, request, slug, pk):
workspace=workspace_invite.workspace,
member=user,
role=workspace_invite.role,
+ access_revoked=False,
)
# Set the user last_workspace_id to the accepted workspace
@@ -287,7 +289,7 @@ def create(self, request):
)
# Update the WorkspaceMember for this specific invitation
WorkspaceMember.objects.filter(workspace_id=invitation.workspace_id, member=request.user).update(
- is_active=True, role=invitation.role
+ is_active=True, role=invitation.role, access_revoked=False
)
# Track event
diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py
index 67c7637a8c2..bb901e007ed 100644
--- a/apps/api/plane/app/views/workspace/member.py
+++ b/apps/api/plane/app/views/workspace/member.py
@@ -143,9 +143,10 @@ def destroy(self, request, slug, pk):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
- ).update(is_active=False, updated_at=timezone.now())
+ ).update(is_active=False, access_revoked=True, updated_at=timezone.now())
workspace_member.is_active = False
+ workspace_member.access_revoked = True
workspace_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -199,8 +200,9 @@ def leave(self, request, slug):
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
- # # Deactivate the user
+ # # Deactivate the user (voluntary leave)
workspace_member.is_active = False
+ workspace_member.access_revoked = False
workspace_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py b/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py
new file mode 100644
index 00000000000..569eaaf15e2
--- /dev/null
+++ b/apps/api/plane/db/migrations/0123_projectmember_access_revoked.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0122_alter_draftissue_assignees_alter_issue_assignees_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="projectmember",
+ name="access_revoked",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py b/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py
new file mode 100644
index 00000000000..ac5ca54b89a
--- /dev/null
+++ b/apps/api/plane/db/migrations/0124_workspacemember_access_revoked.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0123_projectmember_access_revoked"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="workspacemember",
+ name="access_revoked",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py b/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py
new file mode 100644
index 00000000000..243926394bc
--- /dev/null
+++ b/apps/api/plane/db/migrations/0125_mark_inactive_members_access_revoked.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from django.db import migrations
+
+
+def mark_inactive_members_revoked(apps, schema_editor):
+ WorkspaceMember = apps.get_model("db", "WorkspaceMember")
+ ProjectMember = apps.get_model("db", "ProjectMember")
+ WorkspaceMember.objects.filter(is_active=False).update(access_revoked=True)
+ ProjectMember.objects.filter(is_active=False).update(access_revoked=True)
+
+
+def noop_reverse(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0124_workspacemember_access_revoked"),
+ ]
+
+ operations = [
+ migrations.RunPython(mark_inactive_members_revoked, noop_reverse),
+ ]
diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py
index 4039b1d2903..5255b129fd5 100644
--- a/apps/api/plane/db/models/project.py
+++ b/apps/api/plane/db/models/project.py
@@ -222,6 +222,8 @@ class ProjectMember(ProjectBaseModel):
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
is_active = models.BooleanField(default=True)
+ # Set when an admin removes the member; blocks self-join until admin re-adds/invites
+ access_revoked = models.BooleanField(default=False)
def save(self, *args, **kwargs):
if self._state.adding and self.member:
diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py
index 80a3e3e3e42..e60decc5e26 100644
--- a/apps/api/plane/db/models/workspace.py
+++ b/apps/api/plane/db/models/workspace.py
@@ -208,6 +208,8 @@ class WorkspaceMember(BaseModel):
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
is_active = models.BooleanField(default=True)
+ # Set when an admin removes the member; blocks rejoin until invited again
+ access_revoked = models.BooleanField(default=False)
getting_started_checklist = models.JSONField(default=dict)
tips = models.JSONField(default=dict)
explored_features = models.JSONField(default=dict)
diff --git a/apps/api/plane/tests/unit/utils/test_project_access_revoked.py b/apps/api/plane/tests/unit/utils/test_project_access_revoked.py
new file mode 100644
index 00000000000..bcf8b85cd58
--- /dev/null
+++ b/apps/api/plane/tests/unit/utils/test_project_access_revoked.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+import pytest
+
+from plane.db.models import ProjectMember
+
+
+@pytest.mark.unit
+def test_project_member_has_access_revoked_field():
+ field = ProjectMember._meta.get_field("access_revoked")
+ assert field.default is False
diff --git a/apps/web/core/components/project/confirm-project-member-remove.tsx b/apps/web/core/components/project/confirm-project-member-remove.tsx
index 52a7c30600e..96ae8126997 100644
--- a/apps/web/core/components/project/confirm-project-member-remove.tsx
+++ b/apps/web/core/components/project/confirm-project-member-remove.tsx
@@ -73,7 +73,8 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
) : (
<>
Are you sure you want to remove member- {data?.display_name}?
- They will no longer have access to this project. This action cannot be undone.
+ They will no longer have access to this project and cannot rejoin until you add or invite them
+ again.
>
)}
@@ -85,7 +86,7 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
-