Skip to content

Commit 2200e65

Browse files
authored
feat(auth): add safeguards to prevent self-role removal and enforce MANAGE_ACCOUNT role presence (#8729)
1 parent b8537aa commit 2200e65

File tree

5 files changed

+303
-18
lines changed

5 files changed

+303
-18
lines changed

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file.
99

1010
### Changed
1111
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)
12+
- Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729)
1213

1314
---
1415

api/src/backend/api/specs/v1.yaml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6363,8 +6363,10 @@ paths:
63636363
description: ''
63646364
patch:
63656365
operationId: roles_partial_update
6366-
description: Update certain fields of an existing role's information without
6367-
affecting other fields.
6366+
description: Update selected fields on an existing role. When changing the `users`
6367+
relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts
6368+
that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents
6369+
callers from removing their own assignment to that role.
63686370
summary: Partially update a role
63696371
parameters:
63706372
- in: path
@@ -6399,7 +6401,8 @@ paths:
63996401
description: ''
64006402
delete:
64016403
operationId: roles_destroy
6402-
description: Remove a role from the system by their ID.
6404+
description: Delete the specified role. The API rejects deletion of the last
6405+
role in the tenant that grants MANAGE_ACCOUNT.
64036406
summary: Delete a role
64046407
parameters:
64056408
- in: path
@@ -8439,7 +8442,8 @@ paths:
84398442
patch:
84408443
operationId: users_relationships_roles_partial_update
84418444
description: Update the user-roles relationship information without affecting
8442-
other fields.
8445+
other fields. If the update would remove MANAGE_ACCOUNT from the last remaining
8446+
user in the tenant, the API rejects the request with a 400 response.
84438447
summary: Partially update a user-roles relationship
84448448
tags:
84458449
- User
@@ -8463,6 +8467,10 @@ paths:
84638467
delete:
84648468
operationId: users_relationships_roles_destroy
84658469
description: Remove the user-roles relationship from the system by their ID.
8470+
If removing MANAGE_ACCOUNT would take it away from the last remaining user
8471+
in the tenant, the API rejects the request with a 400 response. Users also
8472+
cannot delete their own role assignments; attempting to do so returns a 400
8473+
response.
84668474
summary: Delete a user-roles relationship
84678475
tags:
84688476
- User

api/src/backend/api/tests/test_views.py

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
UserRoleRelationship,
5252
)
5353
from api.rls import Tenant
54+
from api.v1.serializers import TokenSerializer
5455
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
5556

5657

@@ -4720,6 +4721,36 @@ def test_role_clear_relationships(self, authenticated_client, roles_fixture):
47204721
assert role.users.count() == 0
47214722
assert role.provider_groups.count() == 0
47224723

4724+
def test_cannot_remove_own_assignment_via_role_update(
4725+
self, authenticated_client, roles_fixture
4726+
):
4727+
role = roles_fixture[0]
4728+
# Ensure the authenticated user is assigned to this role
4729+
user = User.objects.get(email=TEST_USER)
4730+
if not UserRoleRelationship.objects.filter(user=user, role=role).exists():
4731+
UserRoleRelationship.objects.create(
4732+
user=user, role=role, tenant_id=role.tenant_id
4733+
)
4734+
4735+
# Attempt to update role users to exclude the current user
4736+
data = {
4737+
"data": {
4738+
"id": str(role.id),
4739+
"type": "roles",
4740+
"relationships": {"users": {"data": []}},
4741+
}
4742+
}
4743+
response = authenticated_client.patch(
4744+
reverse("role-detail", kwargs={"pk": role.id}),
4745+
data=json.dumps(data),
4746+
content_type="application/vnd.api+json",
4747+
)
4748+
assert response.status_code == status.HTTP_400_BAD_REQUEST
4749+
assert (
4750+
"cannot remove their own role"
4751+
in response.json()["errors"][0]["detail"].lower()
4752+
)
4753+
47234754
def test_role_create_with_invalid_user_relationship(
47244755
self, authenticated_client, provider_groups_fixture
47254756
):
@@ -4841,16 +4872,135 @@ def test_partial_update_relationship(
48414872
roles_fixture[2].id,
48424873
}
48434874

4844-
def test_destroy_relationship(
4845-
self, authenticated_client, roles_fixture, create_test_user
4875+
def test_destroy_relationship_other_user(
4876+
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
48464877
):
4878+
# Create another user in same tenant and assign a role
4879+
tenant = tenants_fixture[0]
4880+
other_user = User.objects.create_user(
4881+
name="other",
4882+
email="other_user@prowler.com",
4883+
password="TmpPass123@",
4884+
)
4885+
Membership.objects.create(user=other_user, tenant=tenant)
4886+
UserRoleRelationship.objects.create(
4887+
user=other_user, role=roles_fixture[0], tenant_id=tenant.id
4888+
)
4889+
4890+
# Delete roles for the other user (allowed)
48474891
response = authenticated_client.delete(
4848-
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
4892+
reverse("user-roles-relationship", kwargs={"pk": other_user.id}),
48494893
)
48504894
assert response.status_code == status.HTTP_204_NO_CONTENT
4851-
relationships = UserRoleRelationship.objects.filter(role=roles_fixture[0].id)
4895+
relationships = UserRoleRelationship.objects.filter(user=other_user.id)
48524896
assert relationships.count() == 0
48534897

4898+
def test_cannot_delete_own_roles(self, authenticated_client, create_test_user):
4899+
# Attempt to delete own roles should be forbidden
4900+
response = authenticated_client.delete(
4901+
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
4902+
)
4903+
assert response.status_code == status.HTTP_400_BAD_REQUEST
4904+
4905+
def test_prevent_removing_last_manage_account_on_patch(
4906+
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
4907+
):
4908+
# roles_fixture[1] has manage_account=False
4909+
limited_role = roles_fixture[1]
4910+
4911+
# Ensure there is no other user with MANAGE_ACCOUNT in the tenant
4912+
tenant = tenants_fixture[0]
4913+
# Create a secondary user without MANAGE_ACCOUNT
4914+
user2 = User.objects.create_user(
4915+
name="limited_user",
4916+
email="limited_user@prowler.com",
4917+
password="TmpPass123@",
4918+
)
4919+
Membership.objects.create(user=user2, tenant=tenant)
4920+
UserRoleRelationship.objects.create(
4921+
user=user2, role=limited_role, tenant_id=tenant.id
4922+
)
4923+
4924+
# Attempt to switch the only MANAGE_ACCOUNT user to a role without it
4925+
data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
4926+
response = authenticated_client.patch(
4927+
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
4928+
data=data,
4929+
content_type="application/vnd.api+json",
4930+
)
4931+
assert response.status_code == status.HTTP_400_BAD_REQUEST
4932+
assert "MANAGE_ACCOUNT" in response.json()["errors"][0]["detail"]
4933+
4934+
def test_allow_role_change_when_other_user_has_manage_account_on_patch(
4935+
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
4936+
):
4937+
# roles_fixture[1] has manage_account=False, roles_fixture[0] has manage_account=True
4938+
limited_role = roles_fixture[1]
4939+
ma_role = roles_fixture[0]
4940+
4941+
tenant = tenants_fixture[0]
4942+
# Create another user with MANAGE_ACCOUNT
4943+
user2 = User.objects.create_user(
4944+
name="ma_user",
4945+
email="ma_user@prowler.com",
4946+
password="TmpPass123@",
4947+
)
4948+
Membership.objects.create(user=user2, tenant=tenant)
4949+
UserRoleRelationship.objects.create(
4950+
user=user2, role=ma_role, tenant_id=tenant.id
4951+
)
4952+
4953+
# Now changing the first user's roles to a non-MA role should succeed
4954+
data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
4955+
response = authenticated_client.patch(
4956+
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
4957+
data=data,
4958+
content_type="application/vnd.api+json",
4959+
)
4960+
assert response.status_code == status.HTTP_204_NO_CONTENT
4961+
4962+
def test_role_destroy_only_manage_account_blocked(
4963+
self, authenticated_client, tenants_fixture
4964+
):
4965+
# Use a tenant without default admin role (tenant3)
4966+
tenant = tenants_fixture[2]
4967+
user = User.objects.get(email=TEST_USER)
4968+
# Add membership for this tenant
4969+
Membership.objects.create(user=user, tenant=tenant)
4970+
4971+
# Create a single MANAGE_ACCOUNT role in this tenant
4972+
only_role = Role.objects.create(
4973+
name="only_ma",
4974+
tenant=tenant,
4975+
manage_users=True,
4976+
manage_account=True,
4977+
manage_billing=False,
4978+
manage_providers=False,
4979+
manage_integrations=False,
4980+
manage_scans=False,
4981+
unlimited_visibility=False,
4982+
)
4983+
4984+
# Switch token to this tenant
4985+
serializer = TokenSerializer(
4986+
data={
4987+
"type": "tokens",
4988+
"email": TEST_USER,
4989+
"password": TEST_PASSWORD,
4990+
"tenant_id": str(tenant.id),
4991+
}
4992+
)
4993+
serializer.is_valid(raise_exception=True)
4994+
access_token = serializer.validated_data["access"]
4995+
authenticated_client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
4996+
4997+
# Attempt to delete the only MANAGE_ACCOUNT role
4998+
response = authenticated_client.delete(
4999+
reverse("role-detail", kwargs={"pk": only_role.id})
5000+
)
5001+
assert response.status_code == status.HTTP_400_BAD_REQUEST
5002+
assert Role.objects.filter(id=only_role.id).exists()
5003+
48545004
def test_invalid_provider_group_id(self, authenticated_client, create_test_user):
48555005
invalid_id = "non-existent-id"
48565006
data = {"data": [{"type": "provider-groups", "id": invalid_id}]}

api/src/backend/api/v1/serializers.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,34 @@ def update(self, instance, validated_data):
422422
roles = Role.objects.filter(id__in=role_ids)
423423
tenant_id = self.context.get("tenant_id")
424424

425+
# Safeguard: A tenant must always have at least one user with MANAGE_ACCOUNT.
426+
# If the target roles do NOT include MANAGE_ACCOUNT, and the current user is
427+
# the only one in the tenant with MANAGE_ACCOUNT, block the update.
428+
target_includes_manage_account = roles.filter(manage_account=True).exists()
429+
if not target_includes_manage_account:
430+
# Check if any other user has MANAGE_ACCOUNT
431+
other_users_have_manage_account = (
432+
UserRoleRelationship.objects.filter(
433+
tenant_id=tenant_id, role__manage_account=True
434+
)
435+
.exclude(user_id=instance.id)
436+
.exists()
437+
)
438+
439+
# Check if the current user has MANAGE_ACCOUNT
440+
instance_has_manage_account = instance.roles.filter(
441+
tenant_id=tenant_id, manage_account=True
442+
).exists()
443+
444+
# If the current user is the last holder of MANAGE_ACCOUNT, prevent removal
445+
if instance_has_manage_account and not other_users_have_manage_account:
446+
raise serializers.ValidationError(
447+
{
448+
"roles": "At least one user in the tenant must retain MANAGE_ACCOUNT. "
449+
"Assign MANAGE_ACCOUNT to another user before removing it here."
450+
}
451+
)
452+
425453
instance.roles.clear()
426454
new_relationships = [
427455
UserRoleRelationship(user=instance, role=r, tenant_id=tenant_id)
@@ -1741,6 +1769,37 @@ def update(self, instance, validated_data):
17411769

17421770
if "users" in validated_data:
17431771
users = validated_data.pop("users")
1772+
# Prevent a user from removing their own role assignment via Role update
1773+
request = self.context.get("request")
1774+
if request and getattr(request, "user", None):
1775+
request_user = request.user
1776+
is_currently_assigned = instance.users.filter(
1777+
id=request_user.id
1778+
).exists()
1779+
will_be_assigned = any(u.id == request_user.id for u in users)
1780+
if is_currently_assigned and not will_be_assigned:
1781+
raise serializers.ValidationError(
1782+
{"users": "Users cannot remove their own role."}
1783+
)
1784+
1785+
# Safeguard MANAGE_ACCOUNT coverage when updating users of this role
1786+
if instance.manage_account:
1787+
# Existing MANAGE_ACCOUNT assignments on other roles within the tenant
1788+
other_ma_exists = (
1789+
UserRoleRelationship.objects.filter(
1790+
tenant_id=tenant_id, role__manage_account=True
1791+
)
1792+
.exclude(role_id=instance.id)
1793+
.exists()
1794+
)
1795+
1796+
if not other_ma_exists and len(users) == 0:
1797+
raise serializers.ValidationError(
1798+
{
1799+
"users": "At least one user in the tenant must retain MANAGE_ACCOUNT. "
1800+
"Assign this MANAGE_ACCOUNT role to at least one user or ensure another user has it."
1801+
}
1802+
)
17441803
instance.users.clear()
17451804
through_model_instances = [
17461805
UserRoleRelationship(

0 commit comments

Comments
 (0)