Skip to content
Draft
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
23 changes: 19 additions & 4 deletions ansible_base/rbac/api/queries.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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()
56 changes: 52 additions & 4 deletions ansible_base/rbac/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
248 changes: 248 additions & 0 deletions test_app/tests/rbac/api/test_global_team_role_access_bug.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading