From 85bd7c3ca05f54fd61f34d33a8fa9d74a1880b37 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 26 Aug 2024 16:31:42 -0400 Subject: [PATCH] [4.6] Make controller specific team and org roles (#6662) Adds the following managed Role Definitions Controller Team Admin Controller Team Member Controller Organization Admin Controller Organization Member These have the same permission set as the platform roles (without the Controller prefix) Adding members to teams and orgs via the legacy RBAC system will use these role definitions. Other changes: - Bump DAB to 2024.08.22 - Set ALLOW_LOCAL_ASSIGNING_JWT_ROLES to False in defaults.py. This setting prevents assignments to the platform roles (e.g. Team Member). Signed-off-by: Seth Foster --- awx/main/migrations/_dab_rbac.py | 33 ++++++++- awx/main/models/rbac.py | 21 +++++- .../functional/dab_rbac/test_dab_rbac_api.py | 68 ++++++++++++++++++- .../functional/dab_rbac/test_managed_roles.py | 33 ++++++++- .../dab_rbac/test_translation_layer.py | 21 ++++++ awx/main/tests/functional/test_migrations.py | 7 ++ awx/settings/defaults.py | 3 + requirements/requirements_git.txt | 2 +- 8 files changed, 183 insertions(+), 5 deletions(-) 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 7f40dd9763..3af86e803b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -662,6 +662,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..fe63d930ed 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.26#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac]