mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-14 08:28:16 +00:00
feat(auth): add safeguards to prevent self-role removal and enforce MANAGE_ACCOUNT role presence (#8729)
This commit is contained in:
committed by
GitHub
parent
b8537aa22d
commit
2200e65519
@@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
### Changed
|
||||
- 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)
|
||||
- Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6363,8 +6363,10 @@ paths:
|
||||
description: ''
|
||||
patch:
|
||||
operationId: roles_partial_update
|
||||
description: Update certain fields of an existing role's information without
|
||||
affecting other fields.
|
||||
description: Update selected fields on an existing role. When changing the `users`
|
||||
relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts
|
||||
that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents
|
||||
callers from removing their own assignment to that role.
|
||||
summary: Partially update a role
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -6399,7 +6401,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: roles_destroy
|
||||
description: Remove a role from the system by their ID.
|
||||
description: Delete the specified role. The API rejects deletion of the last
|
||||
role in the tenant that grants MANAGE_ACCOUNT.
|
||||
summary: Delete a role
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -8439,7 +8442,8 @@ paths:
|
||||
patch:
|
||||
operationId: users_relationships_roles_partial_update
|
||||
description: Update the user-roles relationship information without affecting
|
||||
other fields.
|
||||
other fields. If the update would remove MANAGE_ACCOUNT from the last remaining
|
||||
user in the tenant, the API rejects the request with a 400 response.
|
||||
summary: Partially update a user-roles relationship
|
||||
tags:
|
||||
- User
|
||||
@@ -8463,6 +8467,10 @@ paths:
|
||||
delete:
|
||||
operationId: users_relationships_roles_destroy
|
||||
description: Remove the user-roles relationship from the system by their ID.
|
||||
If removing MANAGE_ACCOUNT would take it away from the last remaining user
|
||||
in the tenant, the API rejects the request with a 400 response. Users also
|
||||
cannot delete their own role assignments; attempting to do so returns a 400
|
||||
response.
|
||||
summary: Delete a user-roles relationship
|
||||
tags:
|
||||
- User
|
||||
|
||||
@@ -51,6 +51,7 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
from api.v1.serializers import TokenSerializer
|
||||
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
|
||||
|
||||
|
||||
@@ -4720,6 +4721,36 @@ class TestRoleViewSet:
|
||||
assert role.users.count() == 0
|
||||
assert role.provider_groups.count() == 0
|
||||
|
||||
def test_cannot_remove_own_assignment_via_role_update(
|
||||
self, authenticated_client, roles_fixture
|
||||
):
|
||||
role = roles_fixture[0]
|
||||
# Ensure the authenticated user is assigned to this role
|
||||
user = User.objects.get(email=TEST_USER)
|
||||
if not UserRoleRelationship.objects.filter(user=user, role=role).exists():
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user, role=role, tenant_id=role.tenant_id
|
||||
)
|
||||
|
||||
# Attempt to update role users to exclude the current user
|
||||
data = {
|
||||
"data": {
|
||||
"id": str(role.id),
|
||||
"type": "roles",
|
||||
"relationships": {"users": {"data": []}},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("role-detail", kwargs={"pk": role.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
"cannot remove their own role"
|
||||
in response.json()["errors"][0]["detail"].lower()
|
||||
)
|
||||
|
||||
def test_role_create_with_invalid_user_relationship(
|
||||
self, authenticated_client, provider_groups_fixture
|
||||
):
|
||||
@@ -4841,15 +4872,134 @@ class TestUserRoleRelationshipViewSet:
|
||||
roles_fixture[2].id,
|
||||
}
|
||||
|
||||
def test_destroy_relationship(
|
||||
self, authenticated_client, roles_fixture, create_test_user
|
||||
def test_destroy_relationship_other_user(
|
||||
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
|
||||
):
|
||||
# Create another user in same tenant and assign a role
|
||||
tenant = tenants_fixture[0]
|
||||
other_user = User.objects.create_user(
|
||||
name="other",
|
||||
email="other_user@prowler.com",
|
||||
password="TmpPass123@",
|
||||
)
|
||||
Membership.objects.create(user=other_user, tenant=tenant)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=other_user, role=roles_fixture[0], tenant_id=tenant.id
|
||||
)
|
||||
|
||||
# Delete roles for the other user (allowed)
|
||||
response = authenticated_client.delete(
|
||||
reverse("user-roles-relationship", kwargs={"pk": other_user.id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
relationships = UserRoleRelationship.objects.filter(user=other_user.id)
|
||||
assert relationships.count() == 0
|
||||
|
||||
def test_cannot_delete_own_roles(self, authenticated_client, create_test_user):
|
||||
# Attempt to delete own roles should be forbidden
|
||||
response = authenticated_client.delete(
|
||||
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_prevent_removing_last_manage_account_on_patch(
|
||||
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
|
||||
):
|
||||
# roles_fixture[1] has manage_account=False
|
||||
limited_role = roles_fixture[1]
|
||||
|
||||
# Ensure there is no other user with MANAGE_ACCOUNT in the tenant
|
||||
tenant = tenants_fixture[0]
|
||||
# Create a secondary user without MANAGE_ACCOUNT
|
||||
user2 = User.objects.create_user(
|
||||
name="limited_user",
|
||||
email="limited_user@prowler.com",
|
||||
password="TmpPass123@",
|
||||
)
|
||||
Membership.objects.create(user=user2, tenant=tenant)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user2, role=limited_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
# Attempt to switch the only MANAGE_ACCOUNT user to a role without it
|
||||
data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
|
||||
response = authenticated_client.patch(
|
||||
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
|
||||
data=data,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "MANAGE_ACCOUNT" in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_allow_role_change_when_other_user_has_manage_account_on_patch(
|
||||
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
|
||||
):
|
||||
# roles_fixture[1] has manage_account=False, roles_fixture[0] has manage_account=True
|
||||
limited_role = roles_fixture[1]
|
||||
ma_role = roles_fixture[0]
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
# Create another user with MANAGE_ACCOUNT
|
||||
user2 = User.objects.create_user(
|
||||
name="ma_user",
|
||||
email="ma_user@prowler.com",
|
||||
password="TmpPass123@",
|
||||
)
|
||||
Membership.objects.create(user=user2, tenant=tenant)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user2, role=ma_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
# Now changing the first user's roles to a non-MA role should succeed
|
||||
data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
|
||||
response = authenticated_client.patch(
|
||||
reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
|
||||
data=data,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
relationships = UserRoleRelationship.objects.filter(role=roles_fixture[0].id)
|
||||
assert relationships.count() == 0
|
||||
|
||||
def test_role_destroy_only_manage_account_blocked(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
# Use a tenant without default admin role (tenant3)
|
||||
tenant = tenants_fixture[2]
|
||||
user = User.objects.get(email=TEST_USER)
|
||||
# Add membership for this tenant
|
||||
Membership.objects.create(user=user, tenant=tenant)
|
||||
|
||||
# Create a single MANAGE_ACCOUNT role in this tenant
|
||||
only_role = Role.objects.create(
|
||||
name="only_ma",
|
||||
tenant=tenant,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
|
||||
# Switch token to this tenant
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": TEST_USER,
|
||||
"password": TEST_PASSWORD,
|
||||
"tenant_id": str(tenant.id),
|
||||
}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
access_token = serializer.validated_data["access"]
|
||||
authenticated_client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
|
||||
# Attempt to delete the only MANAGE_ACCOUNT role
|
||||
response = authenticated_client.delete(
|
||||
reverse("role-detail", kwargs={"pk": only_role.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert Role.objects.filter(id=only_role.id).exists()
|
||||
|
||||
def test_invalid_provider_group_id(self, authenticated_client, create_test_user):
|
||||
invalid_id = "non-existent-id"
|
||||
|
||||
@@ -422,6 +422,34 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
roles = Role.objects.filter(id__in=role_ids)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
# Safeguard: A tenant must always have at least one user with MANAGE_ACCOUNT.
|
||||
# If the target roles do NOT include MANAGE_ACCOUNT, and the current user is
|
||||
# the only one in the tenant with MANAGE_ACCOUNT, block the update.
|
||||
target_includes_manage_account = roles.filter(manage_account=True).exists()
|
||||
if not target_includes_manage_account:
|
||||
# Check if any other user has MANAGE_ACCOUNT
|
||||
other_users_have_manage_account = (
|
||||
UserRoleRelationship.objects.filter(
|
||||
tenant_id=tenant_id, role__manage_account=True
|
||||
)
|
||||
.exclude(user_id=instance.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Check if the current user has MANAGE_ACCOUNT
|
||||
instance_has_manage_account = instance.roles.filter(
|
||||
tenant_id=tenant_id, manage_account=True
|
||||
).exists()
|
||||
|
||||
# If the current user is the last holder of MANAGE_ACCOUNT, prevent removal
|
||||
if instance_has_manage_account and not other_users_have_manage_account:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"roles": "At least one user in the tenant must retain MANAGE_ACCOUNT. "
|
||||
"Assign MANAGE_ACCOUNT to another user before removing it here."
|
||||
}
|
||||
)
|
||||
|
||||
instance.roles.clear()
|
||||
new_relationships = [
|
||||
UserRoleRelationship(user=instance, role=r, tenant_id=tenant_id)
|
||||
@@ -1741,6 +1769,37 @@ class RoleUpdateSerializer(RoleSerializer):
|
||||
|
||||
if "users" in validated_data:
|
||||
users = validated_data.pop("users")
|
||||
# Prevent a user from removing their own role assignment via Role update
|
||||
request = self.context.get("request")
|
||||
if request and getattr(request, "user", None):
|
||||
request_user = request.user
|
||||
is_currently_assigned = instance.users.filter(
|
||||
id=request_user.id
|
||||
).exists()
|
||||
will_be_assigned = any(u.id == request_user.id for u in users)
|
||||
if is_currently_assigned and not will_be_assigned:
|
||||
raise serializers.ValidationError(
|
||||
{"users": "Users cannot remove their own role."}
|
||||
)
|
||||
|
||||
# Safeguard MANAGE_ACCOUNT coverage when updating users of this role
|
||||
if instance.manage_account:
|
||||
# Existing MANAGE_ACCOUNT assignments on other roles within the tenant
|
||||
other_ma_exists = (
|
||||
UserRoleRelationship.objects.filter(
|
||||
tenant_id=tenant_id, role__manage_account=True
|
||||
)
|
||||
.exclude(role_id=instance.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
if not other_ma_exists and len(users) == 0:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"users": "At least one user in the tenant must retain MANAGE_ACCOUNT. "
|
||||
"Assign this MANAGE_ACCOUNT role to at least one user or ensure another user has it."
|
||||
}
|
||||
)
|
||||
instance.users.clear()
|
||||
through_model_instances = [
|
||||
UserRoleRelationship(
|
||||
|
||||
@@ -902,7 +902,11 @@ class UserViewSet(BaseUserViewset):
|
||||
partial_update=extend_schema(
|
||||
tags=["User"],
|
||||
summary="Partially update a user-roles relationship",
|
||||
description="Update the user-roles relationship information without affecting other fields.",
|
||||
description=(
|
||||
"Update the user-roles relationship information without affecting other fields. "
|
||||
"If the update would remove MANAGE_ACCOUNT from the last remaining user in the "
|
||||
"tenant, the API rejects the request with a 400 response."
|
||||
),
|
||||
responses={
|
||||
204: OpenApiResponse(
|
||||
response=None, description="Relationship updated successfully"
|
||||
@@ -912,7 +916,12 @@ class UserViewSet(BaseUserViewset):
|
||||
destroy=extend_schema(
|
||||
tags=["User"],
|
||||
summary="Delete a user-roles relationship",
|
||||
description="Remove the user-roles relationship from the system by their ID.",
|
||||
description=(
|
||||
"Remove the user-roles relationship from the system by their ID. If removing "
|
||||
"MANAGE_ACCOUNT would take it away from the last remaining user in the tenant, "
|
||||
"the API rejects the request with a 400 response. Users also cannot delete their "
|
||||
"own role assignments; attempting to do so returns a 400 response."
|
||||
),
|
||||
responses={
|
||||
204: OpenApiResponse(
|
||||
response=None, description="Relationship deleted successfully"
|
||||
@@ -932,6 +941,43 @@ class UserRoleRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
def get_queryset(self):
|
||||
return User.objects.filter(membership__tenant__id=self.request.tenant_id)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Prevent deleting role relationships if it would leave the tenant with no
|
||||
users having MANAGE_ACCOUNT. Supports deleting specific roles via JSON:API
|
||||
relationship payload or clearing all roles for the user when no payload.
|
||||
"""
|
||||
user = self.get_object()
|
||||
# Disallow deleting own roles
|
||||
if str(user.id) == str(request.user.id):
|
||||
return Response(
|
||||
data={
|
||||
"detail": "Users cannot delete the relationship with their role."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
tenant_id = self.request.tenant_id
|
||||
payload = request.data if isinstance(request.data, dict) else None
|
||||
|
||||
# If a user has more than one role, we will delete the relationship with the roles in the payload
|
||||
data = payload.get("data") if payload else None
|
||||
if data:
|
||||
try:
|
||||
role_ids = [item["id"] for item in data]
|
||||
except KeyError:
|
||||
role_ids = []
|
||||
roles_to_remove = Role.objects.filter(id__in=role_ids, tenant_id=tenant_id)
|
||||
else:
|
||||
roles_to_remove = user.roles.filter(tenant_id=tenant_id)
|
||||
|
||||
UserRoleRelationship.objects.filter(
|
||||
user=user,
|
||||
tenant_id=tenant_id,
|
||||
role_id__in=roles_to_remove.values_list("id", flat=True),
|
||||
).delete()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = self.get_object()
|
||||
|
||||
@@ -970,12 +1016,6 @@ class UserRoleRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
serializer.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
user = self.get_object()
|
||||
user.roles.clear()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
@@ -2880,13 +2920,11 @@ class InvitationAcceptViewSet(BaseRLSViewSet):
|
||||
partial_update=extend_schema(
|
||||
tags=["Role"],
|
||||
summary="Partially update a role",
|
||||
description="Update certain fields of an existing role's information without affecting other fields.",
|
||||
responses={200: RoleSerializer},
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Role"],
|
||||
summary="Delete a role",
|
||||
description="Remove a role from the system by their ID.",
|
||||
),
|
||||
)
|
||||
class RoleViewSet(BaseRLSViewSet):
|
||||
@@ -2908,6 +2946,14 @@ class RoleViewSet(BaseRLSViewSet):
|
||||
return RoleUpdateSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
@extend_schema(
|
||||
description=(
|
||||
"Update selected fields on an existing role. When changing the `users` "
|
||||
"relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts "
|
||||
"that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents "
|
||||
"callers from removing their own assignment to that role."
|
||||
)
|
||||
)
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
user_role = get_role(request.user)
|
||||
# If the user is the owner of the role, the manage_account field is not editable
|
||||
@@ -2915,6 +2961,12 @@ class RoleViewSet(BaseRLSViewSet):
|
||||
request.data["manage_account"] = str(user_role.manage_account).lower()
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(
|
||||
description=(
|
||||
"Delete the specified role. The API rejects deletion of the last role "
|
||||
"in the tenant that grants MANAGE_ACCOUNT."
|
||||
)
|
||||
)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if (
|
||||
@@ -2922,6 +2974,21 @@ class RoleViewSet(BaseRLSViewSet):
|
||||
): # TODO: Move to a constant/enum (in case other roles are created by default)
|
||||
raise ValidationError(detail="The admin role cannot be deleted.")
|
||||
|
||||
# Prevent deleting the last MANAGE_ACCOUNT role in the tenant
|
||||
if instance.manage_account:
|
||||
has_other_ma = (
|
||||
Role.objects.filter(tenant_id=instance.tenant_id, manage_account=True)
|
||||
.exclude(id=instance.id)
|
||||
.exists()
|
||||
)
|
||||
if not has_other_ma:
|
||||
return Response(
|
||||
data={
|
||||
"detail": "Cannot delete the only role with MANAGE_ACCOUNT in the tenant."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user