diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 4f6e7b24a6..064f5b7f74 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -167,7 +167,7 @@ def migrate_to_new_rbac(apps, schema_editor): perm.delete() managed_definitions = dict() - for role_definition in RoleDefinition.objects.filter(managed=True): + for role_definition in RoleDefinition.objects.filter(managed=True).exclude(name__in=(settings.ANSIBLE_BASE_JWT_MANAGED_ROLES)): permissions = frozenset(role_definition.permissions.values_list('id', flat=True)) managed_definitions[permissions] = role_definition @@ -309,6 +309,16 @@ def setup_managed_role_definitions(apps, schema_editor): to_create['object_admin'].format(cls=cls), f'Has all permissions to a single {cls._meta.verbose_name}', ct, indiv_perms, RoleDefinition ) ) + if cls_name == 'team': + managed_role_definitions.append( + get_or_create_managed( + 'Controller Team Admin', + f'Has all permissions to a single {cls._meta.verbose_name}', + ct, + indiv_perms, + RoleDefinition, + ) + ) if 'org_children' in to_create and (cls_name not in ('organization', 'instancegroup', 'team')): org_child_perms = object_perms.copy() @@ -349,6 +359,18 @@ def setup_managed_role_definitions(apps, schema_editor): RoleDefinition, ) ) + if action == 'member' and cls_name in ('organization', 'team'): + suffix = to_create['special'].format(cls=cls, action=action.title()) + rd_name = f'Controller {suffix}' + managed_role_definitions.append( + get_or_create_managed( + rd_name, + f'Has {action} permissions to a single {cls._meta.verbose_name}', + ct, + perm_list, + RoleDefinition, + ) + ) if 'org_admin' in to_create: managed_role_definitions.append( @@ -360,6 +382,15 @@ def setup_managed_role_definitions(apps, schema_editor): RoleDefinition, ) ) + managed_role_definitions.append( + get_or_create_managed( + 'Controller Organization Admin', + 'Has all permissions to a single organization and all objects inside of it', + org_ct, + org_perms, + RoleDefinition, + ) + ) # Special "organization action" roles audit_permissions = [perm for perm in org_perms if perm.codename.startswith('view_')] diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 71e719ea08..0bff43a3b0 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -557,12 +557,25 @@ def get_role_definition(role): f = obj._meta.get_field(role.role_field) action_name = f.name.rsplit("_", 1)[0] model_print = type(obj).__name__ - rd_name = f'{model_print} {action_name.title()} Compat' perm_list = get_role_codenames(role) defaults = { 'content_type_id': role.content_type_id, 'description': f'Has {action_name.title()} permission to {model_print} for backwards API compatibility', } + # use Controller-specific role definitions for Team/Organization and member/admin + # instead of platform role definitions + # these should exist in the system already, so just do a lookup by role definition name + if model_print in ['Team', 'Organization'] and action_name in ['member', 'admin']: + rd_name = f'Controller {model_print} {action_name.title()}' + rd = RoleDefinition.objects.filter(name=rd_name).first() + if rd: + return rd + else: + return RoleDefinition.objects.create_from_permissions(permissions=perm_list, name=rd_name, managed=True, **defaults) + + else: + rd_name = f'{model_print} {action_name.title()} Compat' + with impersonate(None): try: rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults) @@ -585,6 +598,12 @@ def get_role_from_object_role(object_role): model_name, role_name, _ = rd.name.split() role_name = role_name.lower() role_name += '_role' + elif rd.name.startswith('Controller') and rd.name.endswith(' Admin'): + # Controller Organization Admin and Controller Team Admin + role_name = 'admin_role' + elif rd.name.startswith('Controller') and rd.name.endswith(' Member'): + # Controller Organization Member and Controller Team Member + role_name = 'member_role' elif rd.name.endswith(' Admin') and rd.name.count(' ') == 2: # cases like "Organization Project Admin" model_name, target_model_name, role_name = rd.name.split() diff --git a/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py b/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py index cb0e9ac74a..0f9f88eb01 100644 --- a/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py +++ b/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py @@ -148,9 +148,75 @@ def test_assign_credential_to_user_of_another_org(setup_managed_roles, credentia @pytest.mark.django_db -@override_settings(ALLOW_LOCAL_RESOURCE_MANAGEMENT=False) +@override_settings(ALLOW_LOCAL_ASSIGNING_JWT_ROLES=False) def test_team_member_role_not_assignable(team, rando, post, admin_user, setup_managed_roles): member_rd = RoleDefinition.objects.get(name='Organization Member') url = django_reverse('roleuserassignment-list') r = post(url, data={'object_id': team.id, 'role_definition': member_rd.id, 'user': rando.id}, user=admin_user, expect=400) assert 'Not managed locally' in str(r.data) + + +@pytest.mark.django_db +def test_adding_user_to_org_member_role(setup_managed_roles, organization, admin, bob, post, get): + ''' + Adding user to organization member role via the legacy RBAC endpoints + should give them access to the organization detail + ''' + url_detail = reverse('api:organization_detail', kwargs={'pk': organization.id}) + get(url_detail, user=bob, expect=403) + + role = organization.member_role + url = reverse('api:role_users_list', kwargs={'pk': role.id}) + post(url, data={'id': bob.id}, user=admin, expect=204) + + get(url_detail, user=bob, expect=200) + + +@pytest.mark.django_db +@pytest.mark.parametrize('actor', ['user', 'team']) +@pytest.mark.parametrize('role_name', ['Organization Admin', 'Organization Member', 'Team Admin', 'Team Member']) +def test_prevent_adding_actor_to_platform_roles(setup_managed_roles, role_name, actor, organization, team, admin, bob, post): + ''' + Prevent user or team from being added to platform-level roles + ''' + rd = RoleDefinition.objects.get(name=role_name) + endpoint = 'roleuserassignment-list' if actor == 'user' else 'roleteamassignment-list' + url = django_reverse(endpoint) + object_id = team.id if 'Team' in role_name else organization.id + data = {'object_id': object_id, 'role_definition': rd.id} + actor_id = bob.id if actor == 'user' else team.id + data[actor] = actor_id + r = post(url, data=data, user=admin, expect=400) + assert 'Not managed locally' in str(r.data) + + +@pytest.mark.django_db +@pytest.mark.parametrize('role_name', ['Controller Team Admin', 'Controller Team Member']) +def test_adding_user_to_controller_team_roles(setup_managed_roles, role_name, team, admin, bob, post, get): + ''' + Allow user to be added to Controller Team Admin or Controller Team Member + ''' + url_detail = reverse('api:team_detail', kwargs={'pk': team.id}) + get(url_detail, user=bob, expect=403) + + rd = RoleDefinition.objects.get(name=role_name) + url = django_reverse('roleuserassignment-list') + post(url, data={'object_id': team.id, 'role_definition': rd.id, 'user': bob.id}, user=admin, expect=201) + + get(url_detail, user=bob, expect=200) + + +@pytest.mark.django_db +@pytest.mark.parametrize('role_name', ['Controller Organization Admin', 'Controller Organization Member']) +def test_adding_user_to_controller_organization_roles(setup_managed_roles, role_name, organization, admin, bob, post, get): + ''' + Allow user to be added to Controller Organization Admin or Controller Organization Member + ''' + url_detail = reverse('api:organization_detail', kwargs={'pk': organization.id}) + get(url_detail, user=bob, expect=403) + + rd = RoleDefinition.objects.get(name=role_name) + url = django_reverse('roleuserassignment-list') + post(url, data={'object_id': organization.id, 'role_definition': rd.id, 'user': bob.id}, user=admin, expect=201) + + get(url, user=bob, expect=200) diff --git a/awx/main/tests/functional/dab_rbac/test_managed_roles.py b/awx/main/tests/functional/dab_rbac/test_managed_roles.py index ec7763d618..594428fdef 100644 --- a/awx/main/tests/functional/dab_rbac/test_managed_roles.py +++ b/awx/main/tests/functional/dab_rbac/test_managed_roles.py @@ -1,6 +1,6 @@ import pytest -from ansible_base.rbac.models import RoleDefinition, DABPermission +from ansible_base.rbac.models import RoleDefinition, DABPermission, RoleUserAssignment @pytest.mark.django_db @@ -29,3 +29,34 @@ def test_org_child_add_permission(setup_managed_roles): # special case for JobTemplate, anyone can create one with use permission to project/inventory assert not DABPermission.objects.filter(codename='add_jobtemplate').exists() + + +@pytest.mark.django_db +def test_controller_specific_roles_have_correct_permissions(setup_managed_roles): + ''' + Controller specific roles should have the same permissions as the platform roles + e.g. Controller Team Admin should have same permission set as Team Admin + ''' + for rd_name in ['Controller Team Admin', 'Controller Team Member', 'Controller Organization Member', 'Controller Organization Admin']: + rd = RoleDefinition.objects.get(name=rd_name) + rd_platform = RoleDefinition.objects.get(name=rd_name.split('Controller ')[1]) + assert set(rd.permissions.all()) == set(rd_platform.permissions.all()) + + +@pytest.mark.django_db +@pytest.mark.parametrize('resource_name', ['Team', 'Organization']) +@pytest.mark.parametrize('action', ['Member', 'Admin']) +def test_legacy_RBAC_uses_controller_specific_roles(setup_managed_roles, resource_name, action, team, bob, organization): + ''' + Assignment to legacy RBAC roles should use controller specific role definitions + e.g. Controller Team Admin, Controller Team Member, Controller Organization Member, Controller Organization Admin + ''' + resource = team if resource_name == 'Team' else organization + if action == 'Member': + resource.member_role.members.add(bob) + else: + resource.admin_role.members.add(bob) + rd = RoleDefinition.objects.get(name=f'Controller {resource_name} {action}') + rd_platform = RoleDefinition.objects.get(name=f'{resource_name} {action}') + assert RoleUserAssignment.objects.filter(role_definition=rd, user=bob, object_id=resource.id).exists() + assert not RoleUserAssignment.objects.filter(role_definition=rd_platform, user=bob, object_id=resource.id).exists() diff --git a/awx/main/tests/functional/dab_rbac/test_translation_layer.py b/awx/main/tests/functional/dab_rbac/test_translation_layer.py index dfa019767a..22957e1d4c 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -192,3 +192,24 @@ def test_user_auditor_rel(organization, rando, setup_managed_roles): audit_rd = RoleDefinition.objects.get(name='Organization Audit') audit_rd.give_permission(rando, organization) assert list(rando.auditor_of_organizations) == [organization] + + +@pytest.mark.django_db +@pytest.mark.parametrize('resource_name', ['Organization', 'Team']) +@pytest.mark.parametrize('role_name', ['Member', 'Admin']) +def test_mapping_from_controller_role_definitions_to_roles(organization, team, rando, role_name, resource_name, setup_managed_roles): + """ + ensure mappings for controller roles are correct + e.g. + Controller Organization Member > organization.member_role + Controller Organization Admin > organization.admin_role + Controller Team Member > team.member_role + Controller Team Admin > team.admin_role + """ + resource = organization if resource_name == 'Organization' else team + old_role_name = f"{role_name.lower()}_role" + getattr(resource, old_role_name).members.add(rando) + assignment = RoleUserAssignment.objects.get(user=rando) + assert assignment.role_definition.name == f'Controller {resource_name} {role_name}' + old_role = get_role_from_object_role(assignment.object_role) + assert old_role.id == getattr(resource, old_role_name).id diff --git a/awx/main/tests/functional/test_migrations.py b/awx/main/tests/functional/test_migrations.py index 74e446ce3b..0e14cdc33a 100644 --- a/awx/main/tests/functional/test_migrations.py +++ b/awx/main/tests/functional/test_migrations.py @@ -73,11 +73,16 @@ class TestMigrationSmoke: def test_migrate_DAB_RBAC(self, migrator): old_state = migrator.apply_initial_migration(('main', '0190_alter_inventorysource_source_and_more')) Organization = old_state.apps.get_model('main', 'Organization') + Team = old_state.apps.get_model('main', 'Team') User = old_state.apps.get_model('auth', 'User') org = Organization.objects.create(name='arbitrary-org', created=now(), modified=now()) user = User.objects.create(username='random-user') org.read_role.members.add(user) + org.member_role.members.add(user) + + team = Team.objects.create(name='arbitrary-team', organization=org, created=now(), modified=now()) + team.member_role.members.add(user) new_state = migrator.apply_tested_migration( ('main', '0192_custom_roles'), @@ -85,6 +90,8 @@ class TestMigrationSmoke: RoleUserAssignment = new_state.apps.get_model('dab_rbac', 'RoleUserAssignment') assert RoleUserAssignment.objects.filter(user=user.id, object_id=org.id).exists() + assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Controller Organization Member', object_id=org.id).exists() + assert RoleUserAssignment.objects.filter(user=user.id, role_definition__name='Controller Team Member', object_id=team.id).exists() # Regression testing for bug that comes from current vs past models mismatch RoleDefinition = new_state.apps.get_model('dab_rbac', 'RoleDefinition') diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index f56621089c..6f57730627 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -660,6 +660,9 @@ AWX_AUTO_DEPROVISION_INSTANCES = False # e.g. organizations, teams, and users ALLOW_LOCAL_RESOURCE_MANAGEMENT = True +# If True, allow users to be assigned to roles that were created via JWT +ALLOW_LOCAL_ASSIGNING_JWT_ROLES = False + # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. PENDO_TRACKING_STATE = "off" diff --git a/requirements/requirements_git.txt b/requirements/requirements_git.txt index 760991b156..8aef2acdbb 100644 --- a/requirements/requirements_git.txt +++ b/requirements/requirements_git.txt @@ -2,4 +2,4 @@ git+https://github.com/ansible/system-certifi.git@devel#egg=certifi # Remove pbr from requirements.in when moving ansible-runner to requirements.in git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml -django-ansible-base @ git+https://github.com/ansible/django-ansible-base@2024.8.19#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac] +django-ansible-base @ git+https://github.com/ansible/django-ansible-base@2024.8.22#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac]