diff --git a/ansible_base/rbac/api/queries.py b/ansible_base/rbac/api/queries.py index 13469f246..84d2fd047 100644 --- a/ansible_base/rbac/api/queries.py +++ b/ansible_base/rbac/api/queries.py @@ -1,5 +1,6 @@ from typing import Union +from django.conf import settings from django.db.models import Model from ..models import DABContentType, get_evaluation_model @@ -16,9 +17,16 @@ def assignment_qs_user_to_obj(actor: Model, obj: Union[Model, RemoteObject]): obj_eval_qs = evaluation_cls.objects.filter(object_id=obj.pk, content_type_id=ct.id) obj_assignment_qs = actor.role_assignments.filter(**{f'object_role__{reverse_name}__in': obj_eval_qs}) - global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions__content_type=ct) + # Include direct global assignments based on settings + assignment_qs = obj_assignment_qs + if actor._meta.model_name == 'user' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES: + global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions__content_type=ct) + assignment_qs |= global_assignment_qs + elif actor._meta.model_name == 'team' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES: + global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions__content_type=ct) + assignment_qs |= global_assignment_qs - return (global_assignment_qs | obj_assignment_qs).distinct() + return assignment_qs.distinct() def assignment_qs_user_to_obj_perm(actor: Model, obj: Union[Model, RemoteObject], permission: Model): @@ -30,6 +38,13 @@ def assignment_qs_user_to_obj_perm(actor: Model, obj: Union[Model, RemoteObject] obj_eval_qs = evaluation_cls.objects.filter(codename=permission.codename, object_id=obj.pk, content_type_id=ct.id) obj_assignment_qs = actor.role_assignments.filter(**{f'object_role__{reverse_name}__in': obj_eval_qs}) - global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions=permission) + # Include direct global assignments based on settings + assignment_qs = obj_assignment_qs + if actor._meta.model_name == 'user' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES: + global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions=permission) + assignment_qs |= global_assignment_qs + elif actor._meta.model_name == 'team' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES: + global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions=permission) + assignment_qs |= global_assignment_qs - return (global_assignment_qs | obj_assignment_qs).distinct() + return assignment_qs.distinct() diff --git a/ansible_base/rbac/api/views.py b/ansible_base/rbac/api/views.py index 51874ff98..8084076bf 100644 --- a/ansible_base/rbac/api/views.py +++ b/ansible_base/rbac/api/views.py @@ -324,6 +324,10 @@ def get_data_from_url(self): return (self.permission, self.content_type, self.related_object) def get_queryset(self): + from django.conf import settings + + from ansible_base.rbac.permission_registry import permission_registry + actor_cls = self.get_actor_model() # To satisfy AWX schema generator @@ -343,13 +347,57 @@ def get_queryset(self): obj_eval_qs = evaluation_cls.objects.filter(object_id=obj.pk, content_type_id=ct.id) obj_assignment_qs = assignment_cls.objects.filter(**{f'object_role__{reverse_name}__in': obj_eval_qs}) - if permission: - global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions=permission) - else: - global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions__content_type=ct) + # Build the global assignment queryset based on enabled settings + global_assignment_qs = assignment_cls.objects.none() + + # For users: include direct user global role assignments if enabled + if actor_cls._meta.model_name == 'user' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES: + if permission: + global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions=permission) + else: + global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions__content_type=ct) + + # For teams: include direct team global role assignments if enabled + elif actor_cls._meta.model_name == 'team' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES: + if permission: + global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions=permission) + else: + global_assignment_qs = assignment_cls.objects.filter(content_type=None, role_definition__permissions__content_type=ct) assignment_qs = obj_assignment_qs | global_assignment_qs actor_qs = actor_cls.objects.filter(role_assignments__in=assignment_qs) + + # For users, also include users who are members of teams with global roles + if actor_cls._meta.model_name == 'user' and settings.ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES: + team_model = get_team_model() + team_assignment_cls = team_model._meta.get_field('role_assignments').related_model + + # Find teams with global roles for this permission/content_type + if permission: + global_team_assignment_qs = team_assignment_cls.objects.filter(content_type=None, role_definition__permissions=permission) + else: + global_team_assignment_qs = team_assignment_cls.objects.filter(content_type=None, role_definition__permissions__content_type=ct) + + # Get the teams that have these global assignments + global_teams = team_model.objects.filter(role_assignments__in=global_team_assignment_qs) + + # Find users who are members of these teams + # Users gain team membership through the member_team permission + from ansible_base.rbac.models import ObjectRole + + team_member_codename = permission_registry.team_permission + + # Get object roles that grant team membership to the global teams + # Cast team PKs to strings because object_id is a CharField + team_pks = [str(pk) for pk in global_teams.values_list('pk', flat=True)] + team_member_object_roles = ObjectRole.objects.filter(content_type_id=permission_registry.team_ct_id, object_id__in=team_pks).filter( + **{f'{reverse_name}__codename': team_member_codename} + ) + + # Find users with those object roles + users_via_global_teams = actor_cls.objects.filter(role_assignments__object_role__in=team_member_object_roles) + actor_qs |= users_via_global_teams + if actor_cls._meta.model_name == 'user': actor_qs |= actor_cls.objects.filter(is_superuser=True) return actor_qs.distinct() diff --git a/test_app/tests/rbac/api/test_global_team_role_access_bug.py b/test_app/tests/rbac/api/test_global_team_role_access_bug.py new file mode 100644 index 000000000..56c021352 --- /dev/null +++ b/test_app/tests/rbac/api/test_global_team_role_access_bug.py @@ -0,0 +1,248 @@ +# Generated by Claude Code (claude-sonnet-4-5@20250929) +""" +Tests to identify bugs in the user_role_access view related to: +1. Global team roles not showing correctly +2. Different results for superusers vs ordinary users +""" +import pytest +from rest_framework.test import APIClient + +from ansible_base.lib.utils.response import get_relative_url +from ansible_base.rbac.models import RoleDefinition +from test_app.models import Team, User + + +@pytest.mark.django_db +def test_global_team_role_shows_in_user_access_list(inventory, member_rd, admin_api_client): + """ + Test that users who are members of teams with global roles show up in the user access list. + + Scenario: + 1. Create a team + 2. Give the team a global role (e.g., global inventory view permission) + 3. Make a user a member of that team + 4. Check if the user shows up in the user access list for an inventory + + Expected: The user should show up because they have global access via team membership + Bug: The user might NOT show up because the query only looks at RoleUserAssignment, not RoleTeamAssignment + """ + # Create a global role for inventory viewing + global_inv_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory'], + name='global-view-inv', + content_type=None, # None means it's a global role + ) + + # Create a team and give it the global role + team = Team.objects.create(name='global-access-team', organization=inventory.organization) + global_inv_rd.give_global_permission(team) + + # Create a user and make them a member of the team + user = User.objects.create(username='team-member-with-global-access') + member_rd.give_permission(user, team) + + # Get the user access list for the inventory + url = get_relative_url('role-user-access', kwargs={'pk': inventory.pk, 'model_name': 'aap.inventory'}) + response = admin_api_client.get(url) + assert response.status_code == 200, f"Failed to get user access list: {response.data}" + + # Check if the user shows up in the list + usernames = [user_detail['username'] for user_detail in response.data['results']] + + # This assertion may FAIL if the bug exists + assert user.username in usernames, ( + f"BUG: User '{user.username}' should appear in access list because they are a member of team " + f"'{team.name}' which has global 'view_inventory' permission. " + f"Found users: {usernames}" + ) + + +@pytest.mark.django_db +def test_global_team_role_assignment_details(inventory, member_rd, admin_api_client): + """ + Test the assignment details for a user with global access via team membership. + + This is a drill-down test to see if the assignment details endpoint properly shows + the indirect access via team global role. + """ + # Create a global role for inventory viewing + global_inv_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory'], + name='global-view-inv-detail', + content_type=None, + ) + + # Create a team and give it the global role + team = Team.objects.create(name='global-access-team-detail', organization=inventory.organization) + global_inv_rd.give_global_permission(team) + + # Create a user and make them a member of the team + user = User.objects.create(username='team-member-detail') + member_rd.give_permission(user, team) + + # First, try to get the access list + list_url = get_relative_url('role-user-access', kwargs={'pk': inventory.pk, 'model_name': 'aap.inventory'}) + list_response = admin_api_client.get(list_url) + assert list_response.status_code == 200 + + # Try to find the user in the list + user_found = None + for user_detail in list_response.data['results']: + if user_detail['username'] == user.username: + user_found = user_detail + break + + if user_found is None: + pytest.fail( + f"BUG CONFIRMED: User '{user.username}' not found in access list. " + f"This is the global team role bug - users with global access via team membership don't appear." + ) + + # If the user is found, check the assignment details + detail_url = get_relative_url('role-user-access-assignments', kwargs={'pk': inventory.pk, 'model_name': 'aap.inventory', 'actor_pk': user.pk}) + detail_response = admin_api_client.get(detail_url) + assert detail_response.status_code == 200, f"Failed to get assignment details: {detail_response.data}" + + # Note: The assignment details show the user's DIRECT assignments only. + # In this case, the user has no direct global assignment - only the team does. + # The user's assignment is their team membership, and the team has the global role. + # This is working as designed - the user appears in the access list (which we verified above) + # but their direct assignments only show their team membership, not the team's global assignments. + print(f"User has {detail_response.data['count']} direct assignments") + print(f"Assignments: {detail_response.data['results']}") + + +@pytest.mark.django_db +def test_superuser_vs_ordinary_user_access_list_comparison(inventory, member_rd): + """ + Test if there's a difference between what a superuser sees vs what an ordinary user sees + in the user access list. + + The hint suggests that some permissions may show when viewed by ordinary users + but NOT when viewed by superusers. + + This could be related to the BaseAssignmentViewSet.filter_queryset() method which + filters differently based on has_super_permission(). + """ + # Create a global role for inventory viewing + global_inv_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory'], + name='global-view-inv-superuser-test', + content_type=None, + ) + + # Create a team and give it the global role + team = Team.objects.create(name='global-access-team-super', organization=inventory.organization) + global_inv_rd.give_global_permission(team) + + # Create a user and make them a member of the team + user_with_access = User.objects.create(username='team-member-super-test') + member_rd.give_permission(user_with_access, team) + + # Create a superuser + superuser = User.objects.create(username='superuser-test', is_superuser=True) + + # Create an ordinary user who has view permission to the inventory + ordinary_user = User.objects.create(username='ordinary-user-test') + # Give the ordinary user direct view permission to the inventory + from ansible_base.rbac.models import DABContentType + + view_inv_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory'], + name='view-inv-ordinary', + content_type=DABContentType.objects.get_for_model(inventory), + ) + view_inv_rd.give_permission(ordinary_user, inventory) + + # Get the URL for the user access list + url = get_relative_url('role-user-access', kwargs={'pk': inventory.pk, 'model_name': 'aap.inventory'}) + + # Query as superuser + superuser_client = APIClient() + superuser_client.force_authenticate(user=superuser) + superuser_response = superuser_client.get(url) + assert superuser_response.status_code == 200, f"Superuser query failed: {superuser_response.data}" + superuser_usernames = {u['username'] for u in superuser_response.data['results']} + + # Query as ordinary user + ordinary_client = APIClient() + ordinary_client.force_authenticate(user=ordinary_user) + ordinary_response = ordinary_client.get(url) + assert ordinary_response.status_code == 200, f"Ordinary user query failed: {ordinary_response.data}" + ordinary_usernames = {u['username'] for u in ordinary_response.data['results']} + + # Compare the results + # Note: Superuser should see at least what ordinary user sees, possibly more + in_ordinary_not_super = ordinary_usernames - superuser_usernames + in_super_not_ordinary = superuser_usernames - ordinary_usernames + + if in_ordinary_not_super: + pytest.fail(f"BUG CONFIRMED: Ordinary user sees users that superuser doesn't see! " f"In ordinary but not superuser: {in_ordinary_not_super}") + + # Check if the team member appears for both + if user_with_access.username not in superuser_usernames: + print(f"WARNING: User with global team access not visible to superuser. Usernames: {superuser_usernames}") + + if user_with_access.username not in ordinary_usernames: + print(f"WARNING: User with global team access not visible to ordinary user. Usernames: {ordinary_usernames}") + + # Report findings + print(f"\nSuperuser sees {len(superuser_usernames)} users: {superuser_usernames}") + print(f"Ordinary user sees {len(ordinary_usernames)} users: {ordinary_usernames}") + print(f"Only in superuser view: {in_super_not_ordinary}") + print(f"Only in ordinary view: {in_ordinary_not_super}") + + +@pytest.mark.django_db +def test_multiple_global_team_roles(inventory, member_rd, admin_api_client): + """ + Test a more complex scenario with multiple teams having different global roles. + + This tests if the bug compounds when there are multiple teams with global permissions. + """ + # Create two different global roles + global_view_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory'], + name='global-view-multi', + content_type=None, + ) + + global_change_rd = RoleDefinition.objects.create_from_permissions( + permissions=['view_inventory', 'change_inventory'], + name='global-change-multi', + content_type=None, + ) + + # Create two teams with different global roles + team1 = Team.objects.create(name='global-view-team', organization=inventory.organization) + global_view_rd.give_global_permission(team1) + + team2 = Team.objects.create(name='global-change-team', organization=inventory.organization) + global_change_rd.give_global_permission(team2) + + # Create users and make them members + user1 = User.objects.create(username='user-in-view-team') + member_rd.give_permission(user1, team1) + + user2 = User.objects.create(username='user-in-change-team') + member_rd.give_permission(user2, team2) + + user3 = User.objects.create(username='user-in-both-teams') + member_rd.give_permission(user3, team1) + member_rd.give_permission(user3, team2) + + # Get the user access list + url = get_relative_url('role-user-access', kwargs={'pk': inventory.pk, 'model_name': 'aap.inventory'}) + response = admin_api_client.get(url) + assert response.status_code == 200 + + usernames = {u['username'] for u in response.data['results']} + + # Check all users + missing_users = [] + for user in [user1, user2, user3]: + if user.username not in usernames: + missing_users.append(user.username) + + if missing_users: + pytest.fail(f"BUG CONFIRMED: The following users with global team access are missing: {missing_users}. " f"Found users: {usernames}")