From 9981750b6905ae51cd8a51e793c6f1f25b505780 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Mon, 13 Nov 2023 15:13:25 -0500 Subject: [PATCH 01/20] Replace role system with permissions-based DB roles Develop ability to list permissions for existing roles Create a model registry for RBAC-tracked models Write the data migration logic for creating the preloaded role definitions Write migration to migrate old Role into ObjectRole model This loops over the old Role model, knowing it is unique on object and role_field Most of the logic is concerned with identifying the needed permissions, and then corresponding role definition As needed, object roles are created and users then teams are assigned Write re-computation of cache logic for teams and then for object role permissions Migrate new RBAC internals to ansible_base Migrate tests to ansible_base Implement solution for visible_roles Expose URLs for DAB RBAC --- awx/api/generics.py | 16 +- awx/api/serializers.py | 87 +++++-- awx/api/views/__init__.py | 54 +++- awx/api/views/inventory.py | 1 + awx/api/views/organization.py | 1 + awx/api/views/root.py | 3 + awx/main/access.py | 58 +++-- awx/main/constants.py | 25 ++ .../migrations/0190_add_django_permissions.py | 100 ++++++++ .../0191_profile_is_system_auditor.py | 20 ++ awx/main/migrations/0192_custom_roles.py | 22 ++ awx/main/migrations/_dab_rbac.py | 237 ++++++++++++++++++ awx/main/models/__init__.py | 29 ++- awx/main/models/base.py | 20 ++ awx/main/models/credential/__init__.py | 1 + awx/main/models/ha.py | 3 + awx/main/models/inventory.py | 7 +- awx/main/models/jobs.py | 3 + awx/main/models/mixins.py | 21 +- awx/main/models/organization.py | 29 +++ awx/main/models/projects.py | 1 + awx/main/models/rbac.py | 188 +++++++++++++- awx/main/models/unified_jobs.py | 13 +- awx/main/models/workflow.py | 4 + awx/main/signals.py | 4 + .../functional/analytics/test_metrics.py | 2 - .../tests/functional/api/test_credential.py | 8 +- .../api/test_resource_access_lists.py | 6 +- awx/main/tests/functional/api/test_role.py | 11 - awx/main/tests/functional/conftest.py | 3 +- .../functional/dab_rbac/test_dab_rbac_api.py | 68 +++++ .../dab_rbac/test_translation_layer.py | 23 ++ .../functional/models/test_activity_stream.py | 10 +- .../models/test_context_managers.py | 10 - awx/main/tests/functional/test_rbac_api.py | 22 +- awx/main/tests/functional/test_rbac_core.py | 213 ---------------- .../functional/test_rbac_job_templates.py | 47 +--- .../tests/functional/test_rbac_migration.py | 28 +-- awx/main/tests/functional/test_rbac_team.py | 4 +- awx/main/tests/functional/test_rbac_user.py | 35 +-- awx/main/tests/functional/test_teams.py | 14 -- awx/settings/defaults.py | 33 ++- awx/urls.py | 9 +- awx_collection/test/awx/test_role.py | 4 +- docs/rbac.md | 161 +----------- requirements/requirements_git.txt | 2 +- 46 files changed, 1046 insertions(+), 614 deletions(-) create mode 100644 awx/main/migrations/0190_add_django_permissions.py create mode 100644 awx/main/migrations/0191_profile_is_system_auditor.py create mode 100644 awx/main/migrations/0192_custom_roles.py create mode 100644 awx/main/migrations/_dab_rbac.py create mode 100644 awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py create mode 100644 awx/main/tests/functional/dab_rbac/test_translation_layer.py delete mode 100644 awx/main/tests/functional/test_rbac_core.py delete mode 100644 awx/main/tests/functional/test_teams.py diff --git a/awx/api/generics.py b/awx/api/generics.py index 4020bb67576e..5b68bb419574 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -30,11 +30,15 @@ from rest_framework.renderers import StaticHTMLRenderer from rest_framework.negotiation import DefaultContentNegotiation +# django-ansible-base from ansible_base.rest_filters.rest_framework.field_lookup_backend import FieldLookupBackend from ansible_base.lib.utils.models import get_all_field_names +from ansible_base.rbac.models import RoleEvaluation +from ansible_base.rbac.permission_registry import permission_registry # AWX from awx.main.models import UnifiedJob, UnifiedJobTemplate, User, Role, Credential, WorkflowJobTemplateNode, WorkflowApprovalTemplate +from awx.main.models.rbac import give_creator_permissions from awx.main.access import optimize_queryset from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd, get_object_or_400, decrypt_field, get_awx_version from awx.main.utils.licensing import server_product_name @@ -472,7 +476,11 @@ def skip_related_name(name): class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView): # Base class for a list view that allows creating new objects. - pass + def perform_create(self, serializer): + super().perform_create(serializer) + if serializer.Meta.model in permission_registry.all_registered_models: + if self.request and self.request.user: + give_creator_permissions(self.request.user, serializer.instance) class ParentMixin(object): @@ -799,6 +807,11 @@ def get_queryset(self): obj = self.get_parent_object() content_type = ContentType.objects.get_for_model(obj) + + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True)) + return (User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True)).distinct() + roles = set(Role.objects.filter(content_type=content_type, object_id=obj.id)) ancestors = set() @@ -959,6 +972,7 @@ def post(self, request, *args, **kwargs): ) if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all(): new_obj.admin_role.members.add(request.user) + give_creator_permissions(request.user, new_obj) if sub_objs: permission_check_func = None if hasattr(type(self), 'deep_copy_permission_check_func'): diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 282a40697fa0..2423862d2497 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -21,7 +21,7 @@ # Django from django.conf import settings from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Permission from django.contrib.auth.password_validation import validate_password as django_validate_password from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError @@ -43,11 +43,13 @@ # Django-Polymorphic from polymorphic.models import PolymorphicModel +# django-ansible-base from ansible_base.lib.utils.models import get_type_for_model +from ansible_base.rbac.models import RoleEvaluation # AWX from awx.main.access import get_user_capabilities -from awx.main.constants import ACTIVE_STATES, CENSOR_VALUE +from awx.main.constants import ACTIVE_STATES, CENSOR_VALUE, org_role_to_permission from awx.main.models import ( ActivityStream, AdHocCommand, @@ -102,7 +104,7 @@ CLOUD_INVENTORY_SOURCES, ) from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES -from awx.main.models.rbac import role_summary_fields_generator, RoleAncestorEntry +from awx.main.models.rbac import role_summary_fields_generator, give_creator_permissions, get_role_codenames, to_permissions, get_role_from_object_role from awx.main.fields import ImplicitRoleField from awx.main.utils import ( get_model_for_type, @@ -2763,13 +2765,23 @@ def to_representation(self, user): team_content_type = ContentType.objects.get_for_model(Team) content_type = ContentType.objects.get_for_model(obj) - def get_roles_on_resource(parent_role): - "Returns a string list of the roles a parent_role has for current obj." - return list( - RoleAncestorEntry.objects.filter(ancestor=parent_role, content_type_id=content_type.id, object_id=obj.id) - .values_list('role_field', flat=True) - .distinct() - ) + reversed_org_map = {} + for k, v in org_role_to_permission.items(): + reversed_org_map[v] = k + reversed_role_map = {} + for k, v in to_permissions.items(): + reversed_role_map[v] = k + + def get_roles_from_perms(perm_list): + """given a list of permission codenames return a list of role names""" + role_names = set() + for codename in perm_list: + action = codename.split('_', 1)[0] + if action in reversed_role_map: + role_names.add(reversed_role_map[action]) + elif codename in reversed_org_map: + role_names.add(codename) + return list(role_names) def format_role_perm(role): role_dict = {'id': role.id, 'name': role.name, 'description': role.description} @@ -2786,13 +2798,21 @@ def format_role_perm(role): else: # Singleton roles should not be managed from this view, as per copy/edit rework spec role_dict['user_capabilities'] = {'unattach': False} - return {'role': role_dict, 'descendant_roles': get_roles_on_resource(role)} + + if role.singleton_name: + descendant_perms = list(Permission.objects.filter(content_type=content_type).values_list('codename', flat=True)) + else: + model_name = content_type.model + descendant_perms = [codename for codename in get_role_codenames(role) if codename.endswith(model_name)] + + return {'role': role_dict, 'descendant_roles': get_roles_from_perms(descendant_perms)} def format_team_role_perm(naive_team_role, permissive_role_ids): ret = [] + team = naive_team_role.content_object team_role = naive_team_role if naive_team_role.role_field == 'admin_role': - team_role = naive_team_role.content_object.member_role + team_role = team.member_role for role in team_role.children.filter(id__in=permissive_role_ids).all(): role_dict = { 'id': role.id, @@ -2812,10 +2832,48 @@ def format_team_role_perm(naive_team_role, permissive_role_ids): else: # Singleton roles should not be managed from this view, as per copy/edit rework spec role_dict['user_capabilities'] = {'unattach': False} - ret.append({'role': role_dict, 'descendant_roles': get_roles_on_resource(team_role)}) + + descendant_perms = list( + RoleEvaluation.objects.filter(role__in=team.has_roles.all(), object_id=obj.id, content_type_id=content_type.id) + .values_list('codename', flat=True) + .distinct() + ) + + ret.append({'role': role_dict, 'descendant_roles': get_roles_from_perms(descendant_perms)}) + return ret + + gfk_kwargs = dict(content_type_id=content_type.id, object_id=obj.id) + direct_permissive_role_ids = Role.objects.filter(**gfk_kwargs).values_list('id', flat=True) + + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + ret['summary_fields']['direct_access'] = [] + ret['summary_fields']['indirect_access'] = [] + + new_roles_seen = set() + all_team_roles = set() + all_permissive_role_ids = set() + for evaluation in RoleEvaluation.objects.filter(role__users=user, **gfk_kwargs).prefetch_related('role'): + new_role = evaluation.role + if new_role.id in new_roles_seen: + continue + new_roles_seen.add(new_role.id) + old_role = get_role_from_object_role(new_role) + all_permissive_role_ids.add(old_role.id) + + if int(new_role.object_id) == obj.id and new_role.content_type_id == content_type.id: + ret['summary_fields']['direct_access'].append(format_role_perm(old_role)) + elif new_role.content_type_id == team_content_type.id: + all_team_roles.add(old_role) + else: + ret['summary_fields']['indirect_access'].append(format_role_perm(old_role)) + + ret['summary_fields']['direct_access'].extend( + [y for x in (format_team_role_perm(r, direct_permissive_role_ids) for r in all_team_roles) for y in x] + ) + ret['summary_fields']['direct_access'].extend([y for x in (format_team_role_perm(r, all_permissive_role_ids) for r in all_team_roles) for y in x]) + return ret - direct_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('id', flat=True) all_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('ancestors__id', flat=True) direct_access_roles = user.roles.filter(id__in=direct_permissive_role_ids).all() @@ -3085,6 +3143,7 @@ def create(self, validated_data): if user: credential.admin_role.members.add(user) + give_creator_permissions(user, credential) if team: if not credential.organization or team.organization.id != credential.organization.id: raise serializers.ValidationError({"detail": _("Credential organization must be set and match before assigning to a team")}) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 18b85be01864..fba3260e4c71 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -60,6 +60,9 @@ import pytz from wsgiref.util import FileWrapper +# django-ansible-base +from ansible_base.rbac.models import RoleEvaluation, ObjectRole + # AWX from awx.main.tasks.system import send_notifications, update_inventory_computed_fields from awx.main.access import get_user_queryset @@ -87,6 +90,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.versioning import reverse from awx.main import models +from awx.main.models.rbac import give_creator_permissions, get_role_definition from awx.main.utils import ( camelcase_to_underscore, extract_ansible_vars, @@ -536,10 +540,12 @@ class InstanceGroupAccessList(ResourceAccessList): class InstanceGroupObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.InstanceGroup search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -724,6 +730,7 @@ class TeamUsersList(BaseUsersList): class TeamRolesList(SubListAttachDetachAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializerWithParentAccess metadata_class = RoleMetadata @@ -763,10 +770,12 @@ def post(self, request, *args, **kwargs): class TeamObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.Team search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -784,8 +793,15 @@ def get_queryset(self): self.check_parent_access(team) model_ct = ContentType.objects.get_for_model(self.model) parent_ct = ContentType.objects.get_for_model(self.parent_model) - proj_roles = models.Role.objects.filter(Q(ancestors__content_type=parent_ct) & Q(ancestors__object_id=team.pk), content_type=model_ct) - return self.model.accessible_objects(self.request.user, 'read_role').filter(pk__in=[t.content_object.pk for t in proj_roles]) + + rd = get_role_definition(team.member_role) + role = ObjectRole.objects.filter(object_id=team.id, content_type=parent_ct, role_definition=rd).first() + if role is None: + # Team has no permissions, therefore team has no projects + return self.model.none() + else: + project_qs = self.model.accessible_objects(self.request.user, 'read_role') + return project_qs.filter(id__in=RoleEvaluation.objects.filter(content_type_id=model_ct.id, role=role).values_list('object_id')) class TeamActivityStreamList(SubListAPIView): @@ -800,10 +816,23 @@ def get_queryset(self): self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) + return qs.filter( Q(team=parent) - | Q(project__in=models.Project.accessible_objects(parent.member_role, 'read_role')) - | Q(credential__in=models.Credential.accessible_objects(parent.member_role, 'read_role')) + | Q( + project__in=RoleEvaluation.objects.filter( + role__in=parent.has_roles.all(), content_type_id=ContentType.objects.get_for_model(models.Project).id, codename='view_project' + ) + .values_list('object_id') + .distinct() + ) + | Q( + credential__in=RoleEvaluation.objects.filter( + role__in=parent.has_roles.all(), content_type_id=ContentType.objects.get_for_model(models.Credential).id, codename='view_credential' + ) + .values_list('object_id') + .distinct() + ) ) @@ -1055,10 +1084,12 @@ class ProjectAccessList(ResourceAccessList): class ProjectObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.Project search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -1216,6 +1247,7 @@ def get_queryset(self): class UserRolesList(SubListAttachDetachAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializerWithParentAccess metadata_class = RoleMetadata @@ -1490,10 +1522,12 @@ class CredentialAccessList(ResourceAccessList): class CredentialObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.Credential search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -2285,6 +2319,7 @@ def post(self, request, *args, **kwargs): if ret.status_code == 201: job_template = models.JobTemplate.objects.get(id=ret.data['id']) job_template.admin_role.members.add(request.user) + give_creator_permissions(request.user, job_template) return ret @@ -2832,10 +2867,12 @@ class JobTemplateAccessList(ResourceAccessList): class JobTemplateObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.JobTemplate search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -3218,10 +3255,12 @@ class WorkflowJobTemplateAccessList(ResourceAccessList): class WorkflowJobTemplateObjectRolesList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.WorkflowJobTemplate search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() @@ -4230,6 +4269,7 @@ class ActivityStreamDetail(RetrieveAPIView): class RoleList(ListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer permission_classes = (IsAuthenticated,) @@ -4237,11 +4277,13 @@ class RoleList(ListAPIView): class RoleDetail(RetrieveAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer class RoleUsersList(SubListAttachDetachAPIView): + deprecated = True model = models.User serializer_class = serializers.UserSerializer parent_model = models.Role @@ -4276,6 +4318,7 @@ def post(self, request, *args, **kwargs): class RoleTeamsList(SubListAttachDetachAPIView): + deprecated = True model = models.Team serializer_class = serializers.TeamSerializer parent_model = models.Role @@ -4320,10 +4363,12 @@ def post(self, request, pk, *args, **kwargs): team.member_role.children.remove(role) else: team.member_role.children.add(role) + return Response(status=status.HTTP_204_NO_CONTENT) class RoleParentsList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.Role @@ -4337,6 +4382,7 @@ def get_queryset(self): class RoleChildrenList(SubListAPIView): + deprecated = True model = models.Role serializer_class = serializers.RoleSerializer parent_model = models.Role diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 4085cf9bff3c..fb4f8e482ef2 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -152,6 +152,7 @@ class InventoryObjectRolesList(SubListAPIView): serializer_class = RoleSerializer parent_model = Inventory search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index fc8610d347b3..b82f4b3a4be0 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -226,6 +226,7 @@ class OrganizationObjectRolesList(SubListAPIView): serializer_class = RoleSerializer parent_model = Organization search_fields = ('role_field', 'content_type__model') + deprecated = True def get_queryset(self): po = self.get_parent_object() diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 5e6dfc01b809..a9f973244e16 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -132,6 +132,9 @@ def get(self, request, format=None): data['bulk'] = reverse('api:bulk', request=request) data['analytics'] = reverse('api:analytics_root_view', request=request) data['service_index'] = django_reverse('service-index-root') + data['role_definitions'] = django_reverse('roledefinition-list') + data['role_user_assignments'] = django_reverse('roleuserassignment-list') + data['role_team_assignments'] = django_reverse('roleteamassignment-list') return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index 98a25011d252..8a483b0881dc 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -20,7 +20,9 @@ # Django OAuth Toolkit from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken +# django-ansible-base from ansible_base.lib.utils.validation import to_python_boolean +from ansible_base.rbac.models import RoleEvaluation # AWX from awx.main.utils import ( @@ -72,8 +74,6 @@ WorkflowJobTemplateNode, WorkflowApproval, WorkflowApprovalTemplate, - ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, - ROLE_SINGLETON_SYSTEM_AUDITOR, ) from awx.main.models.mixins import ResourceMixin @@ -264,7 +264,7 @@ def can_admin(self, obj, data): return self.can_change(obj, data) def can_delete(self, obj): - return self.user.is_superuser + return self.user.has_obj_perm(obj, 'delete') def can_copy(self, obj): return self.can_add({'reference_obj': obj}) @@ -651,9 +651,8 @@ def filtered_queryset(self): qs = ( User.objects.filter(pk__in=Organization.accessible_objects(self.user, 'read_role').values('member_role__members')) | User.objects.filter(pk=self.user.id) - | User.objects.filter( - pk__in=Role.objects.filter(singleton_name__in=[ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR]).values('members') - ) + | User.objects.filter(is_superuser=True) + | User.objects.filter(profile__is_system_auditor=True) ).distinct() return qs @@ -711,6 +710,15 @@ def can_admin(self, obj, data, allow_orphans=False, check_setting=True): if not allow_orphans: # in these cases only superusers can modify orphan users return False + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + # Permission granted if the user has all permissions that the target user has + target_perms = set( + RoleEvaluation.objects.filter(role__in=obj.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct() + ) + user_perms = set( + RoleEvaluation.objects.filter(role__in=self.user.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct() + ) + return not (target_perms - user_perms) return not obj.roles.all().exclude(ancestors__in=self.user.roles.all()).exists() else: return self.is_all_org_admin(obj) @@ -949,9 +957,6 @@ def can_admin(self, obj, data): def can_update(self, obj): return self.user in obj.update_role - def can_delete(self, obj): - return self.can_admin(obj, None) - def can_run_ad_hoc_commands(self, obj): return self.user in obj.adhoc_role @@ -1405,8 +1410,12 @@ def can_add(self, data): def can_change(self, obj, data): if obj and obj.organization_id is None: raise PermissionDenied - if self.user not in obj.organization.execution_environment_admin_role: - raise PermissionDenied + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + if not self.user.has_obj_perm(obj, 'change'): + raise PermissionDenied + else: + if self.user not in obj.organization.execution_environment_admin_role: + raise PermissionDenied if data and 'organization' in data: new_org = get_object_from_data('organization', Organization, data, obj=obj) if not new_org or self.user not in new_org.execution_environment_admin_role: @@ -1796,7 +1805,15 @@ def can_start(self, obj, validate_license=True): return True # Standard permissions model without job template involved - if obj.organization and self.user in obj.organization.execute_role: + # NOTE: this is the best we can do without caching way more permissions + from django.contrib.contenttypes.models import ContentType + + filter_kwargs = dict( + content_type_id=ContentType.objects.get_for_model(Organization), + object_id=obj.organization_id, + role_definition__permissions__codename='execute_jobtemplate', + ) + if self.user.has_roles.filter(**filter_kwargs).exists(): return True elif not (obj.job_template or obj.organization): raise PermissionDenied(_('Job has been orphaned from its job template and organization.')) @@ -2592,6 +2609,8 @@ def can_add(self, data): if not JobLaunchConfigAccess(self.user).can_add(data): return False if not data: + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return self.user.has_roles.filter(permission_partials__codename__in=['execute_jobtemplate', 'update_project', 'update_inventory']).exists() return Role.objects.filter(role_field__in=['update_role', 'execute_role'], ancestors__in=self.user.roles.all()).exists() return self.check_related('unified_job_template', UnifiedJobTemplate, data, role_field='execute_role', mandatory=True) @@ -2620,6 +2639,8 @@ class NotificationTemplateAccess(BaseAccess): prefetch_related = ('created_by', 'modified_by', 'organization') def filtered_queryset(self): + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return self.model.access_qs(self.user, 'view') return self.model.objects.filter( Q(organization__in=Organization.accessible_objects(self.user, 'notification_admin_role')) | Q(organization__in=self.user.auditor_of_organizations) ).distinct() @@ -2788,7 +2809,7 @@ def filtered_queryset(self): | Q(notification_template__organization__in=auditing_orgs) | Q(notification__notification_template__organization__in=auditing_orgs) | Q(label__organization__in=auditing_orgs) - | Q(role__in=Role.objects.filter(ancestors__in=self.user.roles.all()) if auditing_orgs else []) + | Q(role__in=Role.visible_roles(self.user) if auditing_orgs else []) ) project_set = Project.accessible_pk_qs(self.user, 'read_role') @@ -2845,13 +2866,10 @@ class RoleAccess(BaseAccess): def filtered_queryset(self): result = Role.visible_roles(self.user) - # Sanity check: is the requesting user an orphaned non-admin/auditor? - # if yes, make system admin/auditor mandatorily visible. - if not self.user.is_superuser and not self.user.is_system_auditor and not self.user.organizations.exists(): - mandatories = ('system_administrator', 'system_auditor') - super_qs = Role.objects.filter(singleton_name__in=mandatories) - result = result | super_qs - return result + # Make system admin/auditor mandatorily visible. + mandatories = ('system_administrator', 'system_auditor') + super_qs = Role.objects.filter(singleton_name__in=mandatories) + return result | super_qs def can_add(self, obj, data): # Unsupported for now diff --git a/awx/main/constants.py b/awx/main/constants.py index 8800edc3346e..115b0626043d 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -114,3 +114,28 @@ # Shared prefetch to use for creating a queryset for the purpose of writing or saving facts HOST_FACTS_FIELDS = ('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id') + +# Data for RBAC compatibility layer +role_name_to_perm_mapping = { + 'adhoc_role': ['adhoc_'], + 'approval_role': ['approve_'], + 'auditor_role': ['audit_'], + 'admin_role': ['change_', 'add_', 'delete_'], + 'execute_role': ['execute_'], + 'read_role': ['view_'], + 'update_role': ['update_'], + 'member_role': ['member_'], + 'use_role': ['use_'], +} + +org_role_to_permission = { + 'notification_admin_role': 'add_notificationtemplate', + 'project_admin_role': 'add_project', + 'execute_role': 'execute_jobtemplate', + 'inventory_admin_role': 'add_inventory', + 'credential_admin_role': 'add_credential', + 'workflow_admin_role': 'add_workflowjobtemplate', + 'job_template_admin_role': 'change_jobtemplate', # TODO: this doesnt really work, solution not clear + 'execution_environment_admin_role': 'add_executionenvironment', + 'auditor_role': 'view_project', # TODO: also doesnt really work +} diff --git a/awx/main/migrations/0190_add_django_permissions.py b/awx/main/migrations/0190_add_django_permissions.py new file mode 100644 index 000000000000..39202cdd2ec3 --- /dev/null +++ b/awx/main/migrations/0190_add_django_permissions.py @@ -0,0 +1,100 @@ +# Generated by Django 4.2.6 on 2023-11-13 20:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0189_inbound_hop_nodes'), + ] + + operations = [ + # Add custom permissions for all special actions, like update, use, adhoc, and so on + migrations.AlterModelOptions( + name='credential', + options={'ordering': ('name',), 'permissions': [('use_credential', 'Can use credential in a job or related resource')]}, + ), + migrations.AlterModelOptions( + name='instancegroup', + options={'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')]}, + ), + migrations.AlterModelOptions( + name='inventory', + options={ + 'ordering': ('name',), + 'permissions': [ + ('use_inventory', 'Can use inventory in a job template'), + ('adhoc_inventory', 'Can run ad hoc commands'), + ('update_inventory', 'Can update inventory sources in inventory'), + ], + 'verbose_name_plural': 'inventories', + }, + ), + migrations.AlterModelOptions( + name='jobtemplate', + options={'ordering': ('name',), 'permissions': [('execute_jobtemplate', 'Can run this job template')]}, + ), + migrations.AlterModelOptions( + name='project', + options={ + 'ordering': ('id',), + 'permissions': [('update_project', 'Can run a project update'), ('use_project', 'Can use project in a job template')], + }, + ), + migrations.AlterModelOptions( + name='workflowjobtemplate', + options={ + 'permissions': [ + ('execute_workflowjobtemplate', 'Can run this workflow job template'), + ('approve_workflowjobtemplate', 'Can approve steps in this workflow job template'), + ] + }, + ), + migrations.AlterModelOptions( + name='organization', + options={ + 'default_permissions': ('change', 'delete', 'view'), + 'ordering': ('name',), + 'permissions': [ + ('member_organization', 'Basic participation permissions for organization'), + ('audit_organization', 'Audit everything inside the organization'), + ], + }, + ), + migrations.AlterModelOptions( + name='team', + options={'ordering': ('organization__name', 'name'), 'permissions': [('member_team', 'Inherit all roles assigned to this team')]}, + ), + # Remove add default permission for a few models + migrations.AlterModelOptions( + name='jobtemplate', + options={ + 'default_permissions': ('change', 'delete', 'view'), + 'ordering': ('name',), + 'permissions': [('execute_jobtemplate', 'Can run this job template')], + }, + ), + migrations.AlterModelOptions( + name='instancegroup', + options={ + 'default_permissions': ('change', 'delete', 'view'), + 'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')], + }, + ), + migrations.CreateModel( + name='DABPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('codename', models.CharField(max_length=100, verbose_name='codename')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='content type')), + ], + options={ + 'verbose_name': 'permission', + 'verbose_name_plural': 'permissions', + 'ordering': ['content_type__model', 'codename'], + 'unique_together': {('content_type', 'codename')}, + }, + ), + ] diff --git a/awx/main/migrations/0191_profile_is_system_auditor.py b/awx/main/migrations/0191_profile_is_system_auditor.py new file mode 100644 index 000000000000..a7f7c18813ad --- /dev/null +++ b/awx/main/migrations/0191_profile_is_system_auditor.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.6 on 2023-11-20 16:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0190_add_django_permissions'), + ] + run_before = [ + ('dab_rbac', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_system_auditor', + field=models.BooleanField(default=False, help_text='Can view everying in the system, proxies to User model'), + ), + ] diff --git a/awx/main/migrations/0192_custom_roles.py b/awx/main/migrations/0192_custom_roles.py new file mode 100644 index 000000000000..ce6b7ba9e156 --- /dev/null +++ b/awx/main/migrations/0192_custom_roles.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.6 on 2023-11-21 02:06 + +from django.db import migrations + +from awx.main.migrations._dab_rbac import migrate_to_new_rbac, create_permissions_as_operation + +from ansible_base.rbac.migrations._managed_definitions import setup_managed_role_definitions + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0191_profile_is_system_auditor'), + ('dab_rbac', '__first__'), + ] + + operations = [ + # make sure permissions and content types have been created by now + # these normally run in a post_migrate signal but we need them for our logic + migrations.RunPython(create_permissions_as_operation, migrations.RunPython.noop), + migrations.RunPython(setup_managed_role_definitions, migrations.RunPython.noop), + migrations.RunPython(migrate_to_new_rbac, migrations.RunPython.noop), + ] diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py new file mode 100644 index 000000000000..18c87697a9f5 --- /dev/null +++ b/awx/main/migrations/_dab_rbac.py @@ -0,0 +1,237 @@ +import json +import logging + +from django.apps import apps as global_apps +from django.db.models import ForeignKey +from django.utils.timezone import now +from ansible_base.rbac.migrations._utils import give_permissions, create_custom_permissions + +from awx.main.fields import ImplicitRoleField +from awx.main.constants import role_name_to_perm_mapping + + +logger = logging.getLogger('awx.main.migrations._dab_rbac') + + +def create_permissions_as_operation(apps, schema_editor): + create_custom_permissions(global_apps.get_app_config("main")) + + +""" +Data structures and methods for the migration of old Role model to ObjectRole +""" + +system_admin = ImplicitRoleField(name='system_administrator') +system_auditor = ImplicitRoleField(name='system_auditor') +system_admin.model = None +system_auditor.model = None + + +def resolve_parent_role(f, role_path): + """ + Given a field and a path declared in parent_role from the field definition, like + execute_role = ImplicitRoleField(parent_role='admin_role') + This expects to be passed in (execute_role object, "admin_role") + It hould return the admin_role from that object + """ + if role_path == 'singleton:system_administrator': + return system_admin + elif role_path == 'singleton:system_auditor': + return system_auditor + else: + related_field = f + current_model = f.model + for related_field_name in role_path.split('.'): + related_field = current_model._meta.get_field(related_field_name) + if isinstance(related_field, ForeignKey) and not isinstance(related_field, ImplicitRoleField): + current_model = related_field.related_model + return related_field + + +def build_role_map(apps): + """ + For the old Role model, this builds and returns dictionaries (children, parents) + which give a global mapping of the ImplicitRoleField instances according to the graph + """ + models = set(apps.get_app_config('main').get_models()) + + all_fields = set() + parents = {} + children = {} + + all_fields.add(system_admin) + all_fields.add(system_auditor) + + for cls in models: + for f in cls._meta.get_fields(): + if isinstance(f, ImplicitRoleField): + all_fields.add(f) + + for f in all_fields: + if f.parent_role is not None: + if isinstance(f.parent_role, str): + parent_roles = [f.parent_role] + else: + parent_roles = f.parent_role + + # SPECIAL CASE: organization auditor_role is not a child of admin_role + # this makes no practical sense and conflicts with expected managed role + # so we put it in as a hack here + if f.name == 'auditor_role' and f.model._meta.model_name == 'organization': + parent_roles.append('admin_role') + + parent_list = [] + for rel_name in parent_roles: + parent_list.append(resolve_parent_role(f, rel_name)) + + parents[f] = parent_list + + # build children lookup from parents lookup + for child_field, parent_list in parents.items(): + for parent_field in parent_list: + children.setdefault(parent_field, []) + children[parent_field].append(child_field) + + return (parents, children) + + +def get_descendents(f, children_map): + """ + Given ImplicitRoleField F and the children mapping, returns all descendents + of that field, as a set of other fields, including itself + """ + ret = {f} + if f in children_map: + for child_field in children_map[f]: + ret.update(get_descendents(child_field, children_map)) + return ret + + +def get_permissions_for_role(role_field, children_map, apps): + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + + perm_list = [] + for child_field in get_descendents(role_field, children_map): + if child_field.name in role_name_to_perm_mapping: + for perm_name in role_name_to_perm_mapping[child_field.name]: + if perm_name == 'add_' and role_field.model._meta.model_name != 'organization': + continue # only organizations can contain add permissions + perm = Permission.objects.filter(content_type=ContentType.objects.get_for_model(child_field.model), codename__startswith=perm_name).first() + if perm is not None and perm not in perm_list: + perm_list.append(perm) + + # special case for two models that have object roles but no organization roles in old system + if role_field.name == 'notification_admin_role' or (role_field.name == 'admin_role' and role_field.model._meta.model_name == 'organization'): + ct = ContentType.objects.get_for_model(apps.get_model('main', 'NotificationTemplate')) + perm_list.extend(list(Permission.objects.filter(content_type=ct))) + if role_field.name == 'execution_environment_admin_role' or (role_field.name == 'admin_role' and role_field.model._meta.model_name == 'organization'): + ct = ContentType.objects.get_for_model(apps.get_model('main', 'ExecutionEnvironment')) + perm_list.extend(list(Permission.objects.filter(content_type=ct))) + + # more special cases for those same above special org-level roles + if role_field.name == 'auditor_role': + for codename in ('view_notificationtemplate', 'view_executionenvironment'): + perm_list.append(Permission.objects.get(codename=codename)) + + return perm_list + + +def migrate_to_new_rbac(apps, schema_editor): + """ + This method moves the assigned permissions from the old rbac.py models + to the new RoleDefinition and ObjectRole models + """ + Role = apps.get_model('main', 'Role') + RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') + Permission = apps.get_model('auth', 'Permission') + migration_time = now() + + # remove add premissions that are not valid for migrations from old versions + for perm_str in ('add_organization', 'add_jobtemplate'): + perm = Permission.objects.filter(codename=perm_str).first() + if perm: + perm.delete() + + managed_definitions = dict() + for role_definition in RoleDefinition.objects.filter(managed=True): + permissions = frozenset(role_definition.permissions.values_list('id', flat=True)) + managed_definitions[permissions] = role_definition + + # Build map of old role model + parents, children = build_role_map(apps) + + # NOTE: this import is expected to break at some point, and then just move the data here + from awx.main.models.rbac import role_descriptions + + for role in Role.objects.prefetch_related('members', 'parents').iterator(): + if role.singleton_name: + continue # only bothering to migrate object roles + + team_roles = [] + for parent in role.parents.all(): + if parent.id not in json.loads(role.implicit_parents): + team_roles.append(parent) + + # we will not create any roles that do not have any users or teams + if not (role.members.all() or team_roles): + logger.debug(f'Skipping role {role.role_field} for {role.content_type.model}-{role.object_id} due to no members') + continue + + # get a list of permissions that the old role would grant + object_cls = apps.get_model(f'main.{role.content_type.model}') + object = object_cls.objects.get(pk=role.object_id) # WORKAROUND, role.content_object does not work in migrations + f = object._meta.get_field(role.role_field) # should be ImplicitRoleField + perm_list = get_permissions_for_role(f, children, apps) + + permissions = frozenset(perm.id for perm in perm_list) + + # With the needed permissions established, obtain the RoleDefinition this will need, priorities: + # 1. If it exists as a managed RoleDefinition then obviously use that + # 2. If we already created this for a prior role, use that + # 3. Create a new RoleDefinition that lists those permissions + if permissions in managed_definitions: + role_definition = managed_definitions[permissions] + else: + action = role.role_field.rsplit('_', 1)[0] # remove the _field ending of the name + role_definition_name = f'{role.content_type.model}-{action}' + + description = role_descriptions[role.role_field] + if type(description) == dict: + if role.content_type.model in description: + description = description.get(role.content_type.model) + else: + description = description.get('default') + if '%s' in description: + description = description % role.content_type.model + + role_definition, created = RoleDefinition.objects.get_or_create( + name=role_definition_name, + defaults={'description': description, 'content_type_id': role.content_type_id, 'created_on': migration_time, 'modified_on': migration_time}, + ) + + if created: + logger.info(f'Created custom Role Definition {role_definition_name}, pk={role_definition.pk}') + role_definition.permissions.set(perm_list) + + # Create the object role and add users to it + give_permissions( + apps, + role_definition, + users=role.members.all(), + teams=[tr.object_id for tr in team_roles], + object_id=role.object_id, + content_type_id=role.content_type_id, + ) + + # migrate is_system_auditor flag, because it is no longer handled by a system role + role = Role.objects.filter(singleton_name='system_auditor').first() + if role: + # if the system auditor role is not present, this is a new install and no users should exist + ct = 0 + for user in role.members.all(): + user.profile.is_system_auditor = True + user.profile.save(update_fields=['is_system_auditor']) + ct += 1 + if ct: + logger.info(f'Migrated {ct} users to new system auditor flag') diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 1a8c088523c3..cf16b9f258f5 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +import json + # Django from django.conf import settings # noqa from django.db import connection @@ -8,7 +10,9 @@ # django-ansible-base from ansible_base.resource_registry.fields import AnsibleResourceField +from ansible_base.rbac import permission_registry from ansible_base.lib.utils.models import prevent_search +from ansible_base.lib.utils.models import user_summary_fields # AWX from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, CLOUD_INVENTORY_SOURCES, VERBOSITY_CHOICES # noqa @@ -102,6 +106,7 @@ User.add_to_class('can_access', check_user_access) User.add_to_class('can_access_with_errors', check_user_access_with_errors) User.add_to_class('resource', AnsibleResourceField(primary_key_field="id")) +User.add_to_class('summary_fields', user_summary_fields) def convert_jsonfields(): @@ -198,7 +203,7 @@ def created(user): def user_is_system_auditor(user): if not hasattr(user, '_is_system_auditor'): if user.pk: - user._is_system_auditor = user.roles.filter(singleton_name='system_auditor', role_field='system_auditor').exists() + user._is_system_auditor = user.profile.is_system_auditor else: # Odd case where user is unsaved, this should never be relied on return False @@ -212,17 +217,13 @@ def user_is_system_auditor(user, tf): # time they've logged in, and we've just created the new User in this # request), we need one to set up the system auditor role user.save() - if tf: - role = Role.singleton('system_auditor') - # must check if member to not duplicate activity stream - if user not in role.members.all(): - role.members.add(user) - user._is_system_auditor = True - else: - role = Role.singleton('system_auditor') - if user in role.members.all(): - role.members.remove(user) - user._is_system_auditor = False + if user.profile.is_system_auditor != bool(tf): + prior_value = user.profile.is_system_auditor + user.profile.is_system_auditor = bool(tf) + user.profile.save(update_fields=['is_system_auditor']) + user._is_system_auditor = user.profile.is_system_auditor + entry = ActivityStream.objects.create(changes=json.dumps({"is_system_auditor": [prior_value, bool(tf)]}), object1='user', operation='update') + entry.user.add(user) User.add_to_class('is_system_auditor', user_is_system_auditor) @@ -290,6 +291,10 @@ def o_auth2_token_get_absolute_url(self, request=None): activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) +# Register models +permission_registry.register(Project, Team, WorkflowJobTemplate, JobTemplate, Inventory, Organization, Credential, NotificationTemplate, ExecutionEnvironment) +permission_registry.register(InstanceGroup, parent_field_name=None) # Not part of an organization + # prevent API filtering on certain Django-supplied sensitive fields prevent_search(User._meta.get_field('password')) prevent_search(OAuth2AccessToken._meta.get_field('token')) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index ce96d0bd3155..1d80923ee28f 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -7,6 +7,9 @@ from django.utils.translation import gettext_lazy as _ from django.utils.timezone import now +# django-ansible-base +from ansible_base.lib.utils.models import get_type_for_model + # Django-CRUM from crum import get_current_user @@ -139,6 +142,23 @@ def update_fields(self, **kwargs): self.save(update_fields=update_fields) return update_fields + def summary_fields(self): + """ + This exists for use by django-ansible-base, + which has standard patterns that differ from AWX, but we enable views from DAB + for those views to list summary_fields for AWX models, those models need to provide this + """ + from awx.api.serializers import SUMMARIZABLE_FK_FIELDS + + model_name = get_type_for_model(self) + related_fields = SUMMARIZABLE_FK_FIELDS.get(model_name, {}) + summary_data = {} + for field_name in related_fields: + fval = getattr(self, field_name, None) + if fval is not None: + summary_data[field_name] = fval + return summary_data + class CreatedModifiedModel(BaseModel): """ diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 5bfbba018480..12b09095c264 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -83,6 +83,7 @@ class Meta: app_label = 'main' ordering = ('name',) unique_together = ('organization', 'name', 'credential_type') + permissions = [('use_credential', 'Can use credential in a job or related resource')] PASSWORD_FIELDS = ['inputs'] FIELDS_TO_PRESERVE_AT_COPY = ['input_sources'] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 5c1f5df81078..8c8c9f919b66 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -485,6 +485,9 @@ def _get_related_jobs(self): class Meta: app_label = 'main' + permissions = [('use_instancegroup', 'Can use instance group in a preference list of a resource')] + # Since this has no direct organization field only superuser can add, so remove add permission + default_permissions = ('change', 'delete', 'view') def set_default_policy_fields(self): self.policy_instance_list = [] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 8554f2f24509..e4310f08ff8d 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -89,6 +89,11 @@ class Meta: verbose_name_plural = _('inventories') unique_together = [('name', 'organization')] ordering = ('name',) + permissions = [ + ('use_inventory', 'Can use inventory in a job template'), + ('adhoc_inventory', 'Can run ad hoc commands'), + ('update_inventory', 'Can update inventory sources in inventory'), + ] organization = models.ForeignKey( 'Organization', @@ -1400,7 +1405,7 @@ def preferred_instance_groups(self): return selected_groups -class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin): +class CustomInventoryScript(CommonModelNameNotUnique): class Meta: app_label = 'main' ordering = ('name',) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 7bd52d44388f..551dd631d942 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -205,6 +205,9 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour class Meta: app_label = 'main' ordering = ('name',) + permissions = [('execute_jobtemplate', 'Can run this job template')] + # Remove add permission, ability to add comes from use permission for inventory, project, credentials + default_permissions = ('change', 'delete', 'view') job_type = models.CharField( max_length=64, diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 210d4f44a1d7..9d224876f49c 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -19,13 +19,14 @@ from ansible_base.lib.utils.models import prevent_search # AWX -from awx.main.models.rbac import Role, RoleAncestorEntry + +from awx.main.models.rbac import Role, RoleAncestorEntry, to_permissions from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices, get_licenser, polymorphic from awx.main.utils.execution_environments import get_default_execution_environment from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted from awx.main.utils.polymorphic import build_polymorphic_ctypes_map from awx.main.fields import AskForField -from awx.main.constants import ACTIVE_STATES +from awx.main.constants import ACTIVE_STATES, org_role_to_permission logger = logging.getLogger('awx.main.models.mixins') @@ -64,6 +65,22 @@ def accessible_pk_qs(cls, accessor, role_field): @staticmethod def _accessible_pk_qs(cls, accessor, role_field, content_types=None): + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + if role_field not in to_permissions and cls._meta.model_name == 'organization': + # superficial alternative for narrow exceptions with org roles + # I think this mostly applies to organization members, which is not fully defined yet + if accessor.is_superuser: + return cls.objects.values_list('id') + from ansible_base.rbac.models import ObjectRole + + codename = org_role_to_permission[role_field] + + return ( + ObjectRole.objects.filter(role_definition__permissions__codename=codename, content_type=ContentType.objects.get_for_model(cls)) + .values_list('object_id') + .distinct() + ) + return cls.access_ids_qs(accessor, to_permissions[role_field], content_types=content_types) if accessor._meta.model_name == 'user': ancestor_roles = accessor.roles.all() elif type(accessor) == Role: diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index bba0bf52c833..c34dacdde515 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -7,6 +7,7 @@ from django.db import models from django.contrib.auth.models import User from django.contrib.sessions.models import Session +from django.contrib.contenttypes.models import ContentType from django.utils.timezone import now as tz_now from django.utils.translation import gettext_lazy as _ @@ -35,6 +36,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi class Meta: app_label = 'main' ordering = ('name',) + permissions = [ + ('member_organization', 'Basic participation permissions for organization'), + ('audit_organization', 'Audit everything inside the organization'), + ] + # Remove add permission, only superuser can add + default_permissions = ('change', 'delete', 'view') instance_groups = OrderedManyToManyField('InstanceGroup', blank=True, through='OrganizationInstanceGroupMembership') galaxy_credentials = OrderedManyToManyField( @@ -137,6 +144,7 @@ class Meta: app_label = 'main' unique_together = [('organization', 'name')] ordering = ('organization__name', 'name') + permissions = [('member_team', 'Inherit all roles assigned to this team')] organization = models.ForeignKey( 'Organization', @@ -174,6 +182,7 @@ class Meta: max_length=1024, default='', ) + is_system_auditor = models.BooleanField(default=False, help_text=_('Can view everying in the system, proxies to User model')) class UserSessionMembership(BaseModel): @@ -208,3 +217,23 @@ def user_get_absolute_url(user, request=None): return reverse('api:user_detail', kwargs={'pk': user.pk}, request=request) User.add_to_class('get_absolute_url', user_get_absolute_url) + + +class DABPermission(models.Model): + """ + This is a partial copy of auth.Permission to be used by DAB RBAC lib + and in order to be consistent with other applications + """ + + name = models.CharField("name", max_length=255) + content_type = models.ForeignKey(ContentType, models.CASCADE, verbose_name="content type") + codename = models.CharField("codename", max_length=100) + + class Meta: + verbose_name = "permission" + verbose_name_plural = "permissions" + unique_together = [["content_type", "codename"]] + ordering = ["content_type__model", "codename"] + + def __str__(self): + return f"<{self.__class__.__name__}: {self.codename}>" diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index a22973dd6210..0a571194b080 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -259,6 +259,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn class Meta: app_label = 'main' ordering = ('id',) + permissions = [('update_project', 'Can run a project update'), ('use_project', 'Can use project in a job template')] default_environment = models.ForeignKey( 'ExecutionEnvironment', diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 9078436404ee..3cfd813948cb 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -9,12 +9,22 @@ # Django from django.db import models, transaction, connection +from django.db.models.signals import m2m_changed +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey from django.utils.translation import gettext_lazy as _ +from django.apps import apps +from django.conf import settings + +# Ansible_base app +from ansible_base.rbac.models import RoleDefinition +from ansible_base.lib.utils.models import get_type_for_model # AWX from awx.api.versioning import reverse +from awx.main.migrations._dab_rbac import build_role_map, get_permissions_for_role +from awx.main.constants import role_name_to_perm_mapping, org_role_to_permission __all__ = [ 'Role', @@ -75,6 +85,11 @@ } +to_permissions = {} +for k, v in role_name_to_perm_mapping.items(): + to_permissions[k] = v[0].strip('_') + + tls = threading.local() # thread local storage @@ -86,10 +101,8 @@ def check_singleton(func): """ def wrapper(*args, **kwargs): - sys_admin = Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR) - sys_audit = Role.singleton(ROLE_SINGLETON_SYSTEM_AUDITOR) user = args[0] - if user in sys_admin or user in sys_audit: + if user.is_superuser or user.is_system_auditor: if len(args) == 2: return args[1] return Role.objects.all() @@ -169,6 +182,27 @@ def get_absolute_url(self, request=None): def __contains__(self, accessor): if accessor._meta.model_name == 'user': + if accessor.is_superuser: + return True + if self.role_field == 'system_administrator': + return accessor.is_superuser + elif self.role_field == 'system_auditor': + return accessor.is_system_auditor + elif self.role_field in ('read_role', 'auditor_role') and accessor.is_system_auditor: + return True + + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + if self.role_field not in to_permissions and self.content_object and self.content_object._meta.model_name == 'organization': + # valid alternative for narrow exceptions with org roles + if self.role_field not in org_role_to_permission: + raise Exception(f'org {self.role_field} evaluated but not a translatable permission') + codename = org_role_to_permission[self.role_field] + + return accessor.has_obj_perm(self.content_object, codename) + + if self.role_field not in to_permissions: + raise Exception(f'{self.role_field} evaluated but not a translatable permission') + return accessor.has_obj_perm(self.content_object, to_permissions[self.role_field]) return self.ancestors.filter(members=accessor).exists() else: raise RuntimeError(f'Role evaluations only valid for users, received {accessor}') @@ -280,6 +314,9 @@ def rebuild_role_ancestor_list(additions, removals): # # + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return + if len(additions) == 0 and len(removals) == 0: return @@ -412,6 +449,12 @@ def filter_visible_roles(user, roles_qs): in their organization, but some of those roles descend from organization admin_role, but not auditor_role. """ + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + from ansible_base.rbac.models import RoleEvaluation + + q = RoleEvaluation.objects.filter(role__in=user.has_roles.all()).values_list('object_id', 'content_type_id').query + return roles_qs.extra(where=[f'(object_id,content_type_id) in ({q})']) + return roles_qs.filter( id__in=RoleAncestorEntry.objects.filter( descendent__in=RoleAncestorEntry.objects.filter(ancestor_id__in=list(user.roles.values_list('id', flat=True))).values_list( @@ -434,6 +477,13 @@ def is_singleton(self): return self.singleton_name in [ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR] +class AncestorManager(models.Manager): + def get_queryset(self): + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + raise RuntimeError('The old RBAC system has been disabled, this should never be called') + return super(AncestorManager, self).get_queryset() + + class RoleAncestorEntry(models.Model): class Meta: app_label = 'main' @@ -451,6 +501,8 @@ class Meta: content_type_id = models.PositiveIntegerField(null=False) object_id = models.PositiveIntegerField(null=False) + objects = AncestorManager() + def role_summary_fields_generator(content_object, role_field): global role_descriptions @@ -479,3 +531,133 @@ def role_summary_fields_generator(content_object, role_field): summary['name'] = role_names[role_field] summary['id'] = getattr(content_object, '{}_id'.format(role_field)) return summary + + +# ----------------- Custom Role Compatibility ------------------------- +# The following are methods to connect this (old) RBAC system to the new +# system which allows custom roles +# this follows the ORM interface layer documented in docs/rbac.md +def get_role_codenames(role): + obj = role.content_object + if obj is None: + return + f = obj._meta.get_field(role.role_field) + parents, children = build_role_map(apps) + return [perm.codename for perm in get_permissions_for_role(f, children, apps)] + + +def get_role_definition(role): + """Given a old-style role, this gives a role definition in the new RBAC system for it""" + obj = role.content_object + if obj is None: + return + f = obj._meta.get_field(role.role_field) + action_name = f.name.rsplit("_", 1)[0] + rd_name = f'{obj._meta.model_name}-{action_name}-compat' + perm_list = get_role_codenames(role) + rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults={'content_type_id': role.content_type_id}) + return rd + + +def get_role_from_object_role(object_role): + """ + Given an object role from the new system, return the corresponding role from the old system + reverses naming from get_role_definition, and the ANSIBLE_BASE_ROLE_PRECREATE setting. + """ + rd = object_role.role_definition + if rd.name.endswith('-compat'): + model_name, role_name, _ = rd.name.split('-') + role_name += '_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('-') + model_cls = apps.get_model('main', target_model_name) + target_model_name = get_type_for_model(model_cls) + if target_model_name == 'notification_template': + target_model_name = 'notification' # total exception + role_name = f'{target_model_name}_admin_role' + elif rd.name.endswith('-admin'): + # cases like "project-admin" + model_name, _ = rd.name.rsplit('-', 1) + role_name = 'admin_role' + else: + model_name, role_name = rd.name.split('-') + role_name += '_role' + return getattr(object_role.content_object, role_name) + + +def give_or_remove_permission(role, actor, giving=True): + obj = role.content_object + if obj is None: + return + rd = get_role_definition(role) + rd.give_or_remove_permission(actor, obj, giving=giving) + + +def give_creator_permissions(user, obj): + RoleDefinition.objects.give_creator_permissions(user, obj) + + +def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs): + if action.startswith('pre_'): + return + + if action == 'post_add': + is_giving = True + elif action == 'post_remove': + is_giving = False + elif action == 'post_clear': + raise RuntimeError('Clearing of role members not supported') + + if reverse: + user = instance + else: + role = instance + + for user_or_role_id in pk_set: + if reverse: + role = Role.objects.get(pk=user_or_role_id) + else: + user = get_user_model().objects.get(pk=user_or_role_id) + give_or_remove_permission(role, user, giving=is_giving) + + +def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs): + if action.startswith('pre_'): + return + + if action == 'post_add': + is_giving = True + elif action == 'post_remove': + is_giving = False + elif action == 'post_clear': + raise RuntimeError('Clearing of role members not supported') + + from awx.main.models.organization import Team + + if reverse: + parent_role = instance + else: + child_role = instance + + for role_id in pk_set: + if reverse: + child_role = Role.objects.get(id=role_id) + else: + parent_role = Role.objects.get(id=role_id) + + # To a fault, we want to avoid running this if triggered from implicit_parents management + # we only want to do anything if we know for sure this is a non-implicit team role + if parent_role.role_field not in ('member_role', 'admin_role') or parent_role.content_type.model != 'team': + return + + # Team member role is a parent of its read role so we want to avoid this + if child_role.role_field == 'read_role' and child_role.content_type.model == 'team': + return + + team = Team.objects.get(pk=parent_role.object_id) + give_or_remove_permission(child_role, team, giving=is_giving) + + +m2m_changed.connect(sync_members_to_new_rbac, Role.members.through) +m2m_changed.connect(sync_parents_to_new_rbac, Role.parents.through) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 0f70dc50fb51..305ca2907307 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -37,7 +37,8 @@ from awx.main.dispatch import get_task_queuename from awx.main.dispatch.control import Control as ControlDispatcher from awx.main.registrar import activity_stream_registrar -from awx.main.models.mixins import ResourceMixin, TaskManagerUnifiedJobMixin, ExecutionEnvironmentMixin +from awx.main.models.mixins import TaskManagerUnifiedJobMixin, ExecutionEnvironmentMixin +from awx.main.models.rbac import to_permissions from awx.main.utils.common import ( camelcase_to_underscore, get_model_for_type, @@ -210,7 +211,15 @@ def accessible_pk_qs(cls, accessor, role_field): # do not use this if in a subclass if cls != UnifiedJobTemplate: return super(UnifiedJobTemplate, cls).accessible_pk_qs(accessor, role_field) - return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=cls._submodels_with_roles()) + from ansible_base.rbac.models import RoleEvaluation + + action = to_permissions[role_field] + + return ( + RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__startswith=action, content_type_id__in=cls._submodels_with_roles()) + .values_list('object_id') + .distinct() + ) def _perform_unique_checks(self, unique_checks): # Handle the list of unique fields returned above. Replace with an diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 9e8eb1a460f2..0451daf5bd7d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -467,6 +467,10 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl class Meta: app_label = 'main' + permissions = [ + ('execute_workflowjobtemplate', 'Can run this workflow job template'), + ('approve_workflowjobtemplate', 'Can approve steps in this workflow job template'), + ] notification_templates_approvals = models.ManyToManyField( "NotificationTemplate", diff --git a/awx/main/signals.py b/awx/main/signals.py index 58ab17c95e4a..e2fb00a9076a 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -126,6 +126,8 @@ def rebuild_role_ancestor_list(reverse, model, instance, pk_set, action, **kwarg def sync_superuser_status_to_rbac(instance, **kwargs): 'When the is_superuser flag is changed on a user, reflect that in the membership of the System Admnistrator role' + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return update_fields = kwargs.get('update_fields', None) if update_fields and 'is_superuser' not in update_fields: return @@ -137,6 +139,8 @@ def sync_superuser_status_to_rbac(instance, **kwargs): def sync_rbac_to_superuser_status(instance, sender, **kwargs): 'When the is_superuser flag is false but a user has the System Admin role, update the database to reflect that' + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return if kwargs['action'] in ['post_add', 'post_remove', 'post_clear']: new_status_value = bool(kwargs['action'] == 'post_add') if hasattr(instance, 'singleton_name'): # duck typing, role.members.add() vs user.roles.add() diff --git a/awx/main/tests/functional/analytics/test_metrics.py b/awx/main/tests/functional/analytics/test_metrics.py index 6192d4e9bd94..4295da0a6ec2 100644 --- a/awx/main/tests/functional/analytics/test_metrics.py +++ b/awx/main/tests/functional/analytics/test_metrics.py @@ -4,7 +4,6 @@ from awx.main import models from awx.main.analytics.metrics import metrics from awx.api.versioning import reverse -from awx.main.models.rbac import Role EXPECTED_VALUES = { 'awx_system_info': 1.0, @@ -66,7 +65,6 @@ def test_metrics_permissions(get, admin, org_admin, alice, bob, organization): organization.auditor_role.members.add(bob) assert get(get_metrics_view_db_only(), user=bob).status_code == 403 - Role.singleton('system_auditor').members.add(bob) bob.is_system_auditor = True assert get(get_metrics_view_db_only(), user=bob).status_code == 200 diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 0cfac1506eac..f95889470258 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -385,10 +385,9 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me @pytest.mark.django_db def test_list_cannot_order_by_encrypted_field(post, get, organization, org_admin, credentialtype_ssh, order_by): for i, password in enumerate(('abc', 'def', 'xyz')): - response = post(reverse('api:credential_list'), {'organization': organization.id, 'name': 'C%d' % i, 'password': password}, org_admin) + post(reverse('api:credential_list'), {'organization': organization.id, 'name': 'C%d' % i, 'password': password}, org_admin, expect=400) - response = get(reverse('api:credential_list'), org_admin, QUERY_STRING='order_by=%s' % order_by, status=400) - assert response.status_code == 400 + get(reverse('api:credential_list'), org_admin, QUERY_STRING='order_by=%s' % order_by, expect=400) @pytest.mark.django_db @@ -399,8 +398,7 @@ def test_inputs_cannot_contain_extra_fields(get, post, organization, admin, cred 'credential_type': credentialtype_ssh.pk, 'inputs': {'invalid_field': 'foo'}, } - response = post(reverse('api:credential_list'), params, admin) - assert response.status_code == 400 + response = post(reverse('api:credential_list'), params, admin, expect=400) assert "'invalid_field' was unexpected" in response.data['inputs'][0] diff --git a/awx/main/tests/functional/api/test_resource_access_lists.py b/awx/main/tests/functional/api/test_resource_access_lists.py index 71d107dbda28..3b524f50f298 100644 --- a/awx/main/tests/functional/api/test_resource_access_lists.py +++ b/awx/main/tests/functional/api/test_resource_access_lists.py @@ -1,7 +1,6 @@ import pytest from awx.api.versioning import reverse -from awx.main.models import Role @pytest.mark.django_db @@ -39,7 +38,7 @@ def test_indirect_access_list(get, organization, project, team_factory, user, ad assert len(team_admin_res['summary_fields']['direct_access']) == 1 assert len(team_admin_res['summary_fields']['indirect_access']) == 0 assert len(admin_res['summary_fields']['direct_access']) == 0 - assert len(admin_res['summary_fields']['indirect_access']) == 1 + assert len(admin_res['summary_fields']['indirect_access']) == 0 # decreased to 0 because system admin role no longer exists project_admin_entry = project_admin_res['summary_fields']['direct_access'][0]['role'] assert project_admin_entry['id'] == project.admin_role.id @@ -52,6 +51,3 @@ def test_indirect_access_list(get, organization, project, team_factory, user, ad assert project_admin_team_member_entry['id'] == project.admin_role.id assert project_admin_team_member_entry['team_id'] == project_admin_team.id assert project_admin_team_member_entry['team_name'] == project_admin_team.name - - admin_entry = admin_res['summary_fields']['indirect_access'][0]['role'] - assert admin_entry['name'] == Role.singleton('system_administrator').name diff --git a/awx/main/tests/functional/api/test_role.py b/awx/main/tests/functional/api/test_role.py index cec31d9d7ede..68ce8855feab 100644 --- a/awx/main/tests/functional/api/test_role.py +++ b/awx/main/tests/functional/api/test_role.py @@ -3,17 +3,6 @@ from awx.api.versioning import reverse -@pytest.mark.django_db -def test_admin_visible_to_orphaned_users(get, alice): - names = set() - - response = get(reverse('api:role_list'), user=alice) - for item in response.data['results']: - names.add(item['name']) - assert 'System Auditor' in names - assert 'System Administrator' in names - - @pytest.mark.django_db @pytest.mark.parametrize('role,code', [('member_role', 400), ('admin_role', 400), ('inventory_admin_role', 204)]) @pytest.mark.parametrize('reversed', [True, False]) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 2c40b6ae0913..8c68bd91eefa 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -32,7 +32,6 @@ Organization, Team, ) -from awx.main.models.rbac import Role from awx.main.models.notifications import NotificationTemplate, Notification from awx.main.models.events import ( JobEvent, @@ -434,7 +433,7 @@ def admin(user): @pytest.fixture def system_auditor(user): u = user('an-auditor', False) - Role.singleton('system_auditor').members.add(u) + u.is_system_auditor = True return u 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 new file mode 100644 index 000000000000..00976f2d2aea --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py @@ -0,0 +1,68 @@ +import pytest + +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse as django_reverse + +from awx.api.versioning import reverse +from awx.main.models import JobTemplate, Inventory, Organization + +from ansible_base.rbac.models import RoleDefinition + + +@pytest.mark.django_db +def test_managed_roles_created(): + "Managed RoleDefinitions are created in post_migration signal, we expect to see them here" + for cls in (JobTemplate, Inventory): + ct = ContentType.objects.get_for_model(cls) + rds = list(RoleDefinition.objects.filter(content_type=ct)) + assert len(rds) > 1 + assert f'{cls._meta.model_name}-admin' in [rd.name for rd in rds] + for rd in rds: + assert rd.managed is True + + +@pytest.mark.django_db +def test_custom_read_role(admin_user, post): + rd_url = django_reverse('roledefinition-list') + resp = post( + url=rd_url, data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['view_inventory']}, user=admin_user, expect=201 + ) + rd_id = resp.data['id'] + rd = RoleDefinition.objects.get(id=rd_id) + assert rd.content_type == ContentType.objects.get_for_model(Inventory) + + +@pytest.mark.django_db +def test_assign_managed_role(admin_user, alice, rando, inventory, post): + rd = RoleDefinition.objects.get(name='inventory-admin') + rd.give_permission(alice, inventory) + # Now that alice has full permissions to the inventory, she will give rando permission + url = django_reverse('roleuserassignment-list') + post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": inventory.id}, user=alice, expect=201) + assert rando.has_obj_perm(inventory, 'change') is True + + +@pytest.mark.django_db +def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch): + rd, _ = RoleDefinition.objects.get_or_create( + name='inventory-delete', permissions=['delete_inventory', 'view_inventory'], content_type=ContentType.objects.get_for_model(Inventory) + ) + rd.give_permission(rando, inventory) + inv_id = inventory.pk + inv_url = reverse('api:inventory_detail', kwargs={'pk': inv_id}) + patch(url=inv_url, data={"description": "new"}, user=rando, expect=403) + delete(url=inv_url, user=rando, expect=202) + assert Inventory.objects.get(id=inv_id).pending_deletion + + +@pytest.mark.django_db +def test_assign_custom_add_role(admin_user, rando, organization, post): + rd, _ = RoleDefinition.objects.get_or_create( + name='inventory-add', permissions=['add_inventory', 'view_organization'], content_type=ContentType.objects.get_for_model(Organization) + ) + rd.give_permission(rando, organization) + url = reverse('api:inventory_list') + r = post(url=url, data={'name': 'abc', 'organization': organization.id}, user=rando, expect=201) + inv_id = r.data['id'] + inventory = Inventory.objects.get(id=inv_id) + assert rando.has_obj_perm(inventory, 'change') diff --git a/awx/main/tests/functional/dab_rbac/test_translation_layer.py b/awx/main/tests/functional/dab_rbac/test_translation_layer.py new file mode 100644 index 000000000000..7303fe0ae7bd --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -0,0 +1,23 @@ +import pytest + +from awx.main.models.rbac import get_role_from_object_role + +from ansible_base.rbac.models import RoleUserAssignment + + +@pytest.mark.django_db +@pytest.mark.parametrize( + 'role_name', + ['execution_environment_admin_role', 'project_admin_role', 'admin_role', 'auditor_role', 'read_role', 'execute_role', 'notification_admin_role'], +) +def test_round_trip_roles(organization, rando, role_name): + """ + Make an assignment with the old-style role, + get the equivelent new role + get the old role again + """ + getattr(organization, role_name).members.add(rando) + assignment = RoleUserAssignment.objects.get(user=rando) + print(assignment.role_definition.name) + old_role = get_role_from_object_role(assignment.object_role) + assert old_role.id == getattr(organization, role_name).id diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py index f8ae40b54054..8be052628d97 100644 --- a/awx/main/tests/functional/models/test_activity_stream.py +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -104,11 +104,13 @@ def test_auditor_is_recorded(self, post, value): else: assert len(entry_qs) == 1 # unfortunate, the original creation does _not_ set a real is_auditor field - assert 'is_system_auditor' not in json.loads(entry_qs[0].changes) + assert 'is_system_auditor' not in json.loads(entry_qs[0].changes) # NOTE: if this fails, see special note + # special note - if system auditor flag is moved to user model then we expect this assertion to be changed + # make sure that an extra entry is not created, expectation for count would change to 1 if value: - auditor_changes = json.loads(entry_qs[1].changes) - assert auditor_changes['object2'] == 'user' - assert auditor_changes['object2_pk'] == u.pk + entry = entry_qs[1] + assert json.loads(entry.changes) == {'is_system_auditor': [False, True]} + assert entry.object1 == 'user' def test_user_no_op_api(self, system_auditor): as_ct = ActivityStream.objects.count() diff --git a/awx/main/tests/functional/models/test_context_managers.py b/awx/main/tests/functional/models/test_context_managers.py index 9807d8a6e9cb..271f88b21f57 100644 --- a/awx/main/tests/functional/models/test_context_managers.py +++ b/awx/main/tests/functional/models/test_context_managers.py @@ -1,7 +1,6 @@ import pytest # AWX context managers for testing -from awx.main.models.rbac import batch_role_ancestor_rebuilding from awx.main.signals import disable_activity_stream, disable_computed_fields, update_inventory_computed_fields # AWX models @@ -10,15 +9,6 @@ from awx.main.tests.functional import immediate_on_commit -@pytest.mark.django_db -def test_rbac_batch_rebuilding(rando, organization): - with batch_role_ancestor_rebuilding(): - organization.admin_role.members.add(rando) - inventory = organization.inventories.create(name='test-inventory') - assert rando not in inventory.admin_role - assert rando in inventory.admin_role - - @pytest.mark.django_db def test_disable_activity_stream(): with disable_activity_stream(): diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index b697ef3144c2..ac0179d7b890 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -3,7 +3,7 @@ from django.db import transaction from awx.api.versioning import reverse -from awx.main.models.rbac import Role, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR +from awx.main.models.rbac import Role @pytest.fixture @@ -31,8 +31,6 @@ def test_get_roles_list_user(organization, inventory, team, get, user): 'Users can see all roles they have access to, but not all roles' this_user = user('user-test_get_roles_list_user') organization.member_role.members.add(this_user) - custom_role = Role.objects.create(role_field='custom_role-test_get_roles_list_user') - organization.member_role.children.add(custom_role) url = reverse('api:role_list') response = get(url, this_user) @@ -46,10 +44,8 @@ def test_get_roles_list_user(organization, inventory, team, get, user): for r in roles['results']: role_hash[r['id']] = r - assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id in role_hash assert organization.admin_role.id in role_hash assert organization.member_role.id in role_hash - assert custom_role.id in role_hash assert inventory.admin_role.id not in role_hash assert team.member_role.id not in role_hash @@ -57,7 +53,8 @@ def test_get_roles_list_user(organization, inventory, team, get, user): @pytest.mark.django_db def test_roles_visibility(get, organization, project, admin, alice, bob): - Role.singleton('system_auditor').members.add(alice) + alice.is_system_auditor = True + alice.save() assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=admin).data['count'] == 1 assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=alice).data['count'] == 1 assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=bob).data['count'] == 0 @@ -67,7 +64,8 @@ def test_roles_visibility(get, organization, project, admin, alice, bob): @pytest.mark.django_db def test_roles_filter_visibility(get, organization, project, admin, alice, bob): - Role.singleton('system_auditor').members.add(alice) + alice.is_system_auditor = True + alice.save() project.update_role.members.add(admin) assert get(reverse('api:user_roles_list', kwargs={'pk': admin.id}) + '?id=%d' % project.update_role.id, user=admin).data['count'] == 1 @@ -105,15 +103,6 @@ def test_cant_delete_role(delete, admin, inventory): # -@pytest.mark.django_db -def test_get_user_roles_list(get, admin): - url = reverse('api:user_roles_list', kwargs={'pk': admin.id}) - response = get(url, admin) - assert response.status_code == 200 - roles = response.data - assert roles['count'] > 0 # 'system_administrator' role if nothing else - - @pytest.mark.django_db def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob): 'Users can see roles for other users, but only the roles that that user has access to see as well' @@ -141,7 +130,6 @@ def test_user_view_other_user_roles(organization, inventory, team, get, alice, b assert organization.admin_role.id in role_hash assert custom_role.id not in role_hash # doesn't show up in the user roles list, not an explicit grant - assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id not in role_hash assert inventory.admin_role.id not in role_hash assert team.member_role.id not in role_hash # alice can't see this diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py deleted file mode 100644 index 1fa0f11ed5e8..000000000000 --- a/awx/main/tests/functional/test_rbac_core.py +++ /dev/null @@ -1,213 +0,0 @@ -import pytest - -from awx.main.models import ( - Role, - Organization, - Project, -) -from awx.main.fields import update_role_parentage_for_instance - - -@pytest.mark.django_db -def test_auto_inheritance_by_children(organization, alice): - A = Role.objects.create() - B = Role.objects.create() - A.members.add(alice) - - assert alice not in organization.admin_role - assert Organization.accessible_objects(alice, 'admin_role').count() == 0 - A.children.add(B) - assert alice not in organization.admin_role - assert Organization.accessible_objects(alice, 'admin_role').count() == 0 - A.children.add(organization.admin_role) - assert alice in organization.admin_role - assert Organization.accessible_objects(alice, 'admin_role').count() == 1 - A.children.remove(organization.admin_role) - assert alice not in organization.admin_role - B.children.add(organization.admin_role) - assert alice in organization.admin_role - B.children.remove(organization.admin_role) - assert alice not in organization.admin_role - assert Organization.accessible_objects(alice, 'admin_role').count() == 0 - - # We've had the case where our pre/post save init handlers in our field descriptors - # end up creating a ton of role objects because of various not-so-obvious issues - assert Role.objects.count() < 50 - - -@pytest.mark.django_db -def test_auto_inheritance_by_parents(organization, alice): - A = Role.objects.create() - B = Role.objects.create() - A.members.add(alice) - - assert alice not in organization.admin_role - B.parents.add(A) - assert alice not in organization.admin_role - organization.admin_role.parents.add(A) - assert alice in organization.admin_role - organization.admin_role.parents.remove(A) - assert alice not in organization.admin_role - organization.admin_role.parents.add(B) - assert alice in organization.admin_role - organization.admin_role.parents.remove(B) - assert alice not in organization.admin_role - - -@pytest.mark.django_db -def test_accessible_objects(organization, alice, bob): - A = Role.objects.create() - A.members.add(alice) - B = Role.objects.create() - B.members.add(alice) - B.members.add(bob) - - assert Organization.accessible_objects(alice, 'admin_role').count() == 0 - assert Organization.accessible_objects(bob, 'admin_role').count() == 0 - A.children.add(organization.admin_role) - assert Organization.accessible_objects(alice, 'admin_role').count() == 1 - assert Organization.accessible_objects(bob, 'admin_role').count() == 0 - - -@pytest.mark.django_db -def test_team_symantics(organization, team, alice): - assert alice not in organization.auditor_role - team.member_role.children.add(organization.auditor_role) - assert alice not in organization.auditor_role - team.member_role.members.add(alice) - assert alice in organization.auditor_role - team.member_role.members.remove(alice) - assert alice not in organization.auditor_role - - -@pytest.mark.django_db -def test_auto_field_adjustments(organization, inventory, team, alice): - 'Ensures the auto role reparenting is working correctly through non m2m fields' - org2 = Organization.objects.create(name='Org 2', description='org 2') - org2.admin_role.members.add(alice) - assert alice not in inventory.admin_role - inventory.organization = org2 - inventory.save() - assert alice in inventory.admin_role - inventory.organization = organization - inventory.save() - assert alice not in inventory.admin_role - # assert False - - -@pytest.mark.django_db -def test_implicit_deletes(alice): - 'Ensures implicit resources and roles delete themselves' - delorg = Organization.objects.create(name='test-org') - child = Role.objects.create() - child.parents.add(delorg.admin_role) - delorg.admin_role.members.add(alice) - - admin_role_id = delorg.admin_role.id - auditor_role_id = delorg.auditor_role.id - - assert child.ancestors.count() > 1 - assert Role.objects.filter(id=admin_role_id).count() == 1 - assert Role.objects.filter(id=auditor_role_id).count() == 1 - n_alice_roles = alice.roles.count() - n_system_admin_children = Role.singleton('system_administrator').children.count() - - delorg.delete() - - assert Role.objects.filter(id=admin_role_id).count() == 0 - assert Role.objects.filter(id=auditor_role_id).count() == 0 - assert alice.roles.count() == (n_alice_roles - 1) - assert Role.singleton('system_administrator').children.count() == (n_system_admin_children - 1) - assert child.ancestors.count() == 1 - assert child.ancestors.all()[0] == child - - -@pytest.mark.django_db -def test_content_object(user): - 'Ensure our content_object stuf seems to be working' - - org = Organization.objects.create(name='test-org') - assert org.admin_role.content_object.id == org.id - - -@pytest.mark.django_db -def test_hierarchy_rebuilding_multi_path(): - 'Tests a subdtle cases around role hierarchy rebuilding when you have multiple paths to the same role of different length' - - X = Role.objects.create() - A = Role.objects.create() - B = Role.objects.create() - C = Role.objects.create() - D = Role.objects.create() - - A.children.add(B) - A.children.add(D) - B.children.add(C) - C.children.add(D) - - assert A.is_ancestor_of(D) - assert X.is_ancestor_of(D) is False - - X.children.add(A) - - assert X.is_ancestor_of(D) is True - - X.children.remove(A) - - # This can be the stickler, the rebuilder needs to ensure that D's role - # hierarchy is built after both A and C are updated. - assert X.is_ancestor_of(D) is False - - -@pytest.mark.django_db -def test_auto_parenting(): - org1 = Organization.objects.create(name='org1') - org2 = Organization.objects.create(name='org2') - - prj1 = Project.objects.create(name='prj1') - prj2 = Project.objects.create(name='prj2') - - assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False - - prj1.organization = org1 - prj1.save() - - assert org1.admin_role.is_ancestor_of(prj1.admin_role) - assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False - - prj2.organization = org1 - prj2.save() - - assert org1.admin_role.is_ancestor_of(prj1.admin_role) - assert org1.admin_role.is_ancestor_of(prj2.admin_role) - assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False - - prj1.organization = org2 - prj1.save() - - assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org1.admin_role.is_ancestor_of(prj2.admin_role) - assert org2.admin_role.is_ancestor_of(prj1.admin_role) - assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False - - prj2.organization = org2 - prj2.save() - - assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False - assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False - assert org2.admin_role.is_ancestor_of(prj1.admin_role) - assert org2.admin_role.is_ancestor_of(prj2.admin_role) - - -@pytest.mark.django_db -def test_update_parents_keeps_teams(team, project): - project.update_role.parents.add(team.member_role) - assert list(Project.accessible_objects(team.member_role, 'update_role')) == [project] # test prep sanity check - update_role_parentage_for_instance(project) - assert list(Project.accessible_objects(team.member_role, 'update_role')) == [project] # actual assertion diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index bccec0a1c2e1..5af5c9707bb8 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -4,7 +4,7 @@ from awx.api.versioning import reverse from awx.main.access import BaseAccess, JobTemplateAccess, ScheduleAccess from awx.main.models.jobs import JobTemplate -from awx.main.models import Project, Organization, Inventory, Schedule, User +from awx.main.models import Project, Organization, Schedule @mock.patch.object(BaseAccess, 'check_license', return_value=None) @@ -283,48 +283,3 @@ def test_orphan_JT_adoption(self, project, patch, admin_user, org_admin): assert org_admin not in jt.admin_role patch(url=jt.get_absolute_url(), data={'project': project.id}, user=admin_user, expect=200) assert org_admin in jt.admin_role - - def test_inventory_read_transfer_direct(self, patch): - orgs = [] - invs = [] - admins = [] - for i in range(2): - org = Organization.objects.create(name='org{}'.format(i)) - org_admin = User.objects.create(username='user{}'.format(i)) - inv = Inventory.objects.create(organization=org, name='inv{}'.format(i)) - org.auditor_role.members.add(org_admin) - - orgs.append(org) - admins.append(org_admin) - invs.append(inv) - - jt = JobTemplate.objects.create(name='foo', inventory=invs[0]) - assert admins[0] in jt.read_role - assert admins[1] not in jt.read_role - - jt.inventory = invs[1] - jt.save(update_fields=['inventory']) - assert admins[0] not in jt.read_role - assert admins[1] in jt.read_role - - def test_inventory_read_transfer_indirect(self, patch): - orgs = [] - admins = [] - for i in range(2): - org = Organization.objects.create(name='org{}'.format(i)) - org_admin = User.objects.create(username='user{}'.format(i)) - org.auditor_role.members.add(org_admin) - - orgs.append(org) - admins.append(org_admin) - - inv = Inventory.objects.create(organization=orgs[0], name='inv{}'.format(i)) - - jt = JobTemplate.objects.create(name='foo', inventory=inv) - assert admins[0] in jt.read_role - assert admins[1] not in jt.read_role - - inv.organization = orgs[1] - inv.save(update_fields=['organization']) - assert admins[0] not in jt.read_role - assert admins[1] in jt.read_role diff --git a/awx/main/tests/functional/test_rbac_migration.py b/awx/main/tests/functional/test_rbac_migration.py index 5f1b2633e86e..8ee411ba1a5a 100644 --- a/awx/main/tests/functional/test_rbac_migration.py +++ b/awx/main/tests/functional/test_rbac_migration.py @@ -1,9 +1,7 @@ import pytest -from django.apps import apps - from awx.main.migrations import _rbac as rbac -from awx.main.models import UnifiedJobTemplate, InventorySource, Inventory, JobTemplate, Project, Organization, User +from awx.main.models import UnifiedJobTemplate, InventorySource, Inventory, JobTemplate, Project, Organization @pytest.mark.django_db @@ -49,27 +47,3 @@ def test_implied_organization_subquery_job_template(): assert jt.test_field is None else: assert jt.test_field == jt.project.organization_id - - -@pytest.mark.django_db -def test_give_explicit_inventory_permission(): - dual_admin = User.objects.create(username='alice') - inv_admin = User.objects.create(username='bob') - inv_org = Organization.objects.create(name='inv-org') - proj_org = Organization.objects.create(name='proj-org') - - inv_org.admin_role.members.add(inv_admin, dual_admin) - proj_org.admin_role.members.add(dual_admin) - - proj = Project.objects.create(name="test-proj", organization=proj_org) - inv = Inventory.objects.create(name='test-inv', organization=inv_org) - - jt = JobTemplate.objects.create(name='foo', project=proj, inventory=inv) - - assert dual_admin in jt.admin_role - - rbac.restore_inventory_admins(apps, None) - - assert inv_admin in jt.admin_role.members.all() - assert dual_admin not in jt.admin_role.members.all() - assert dual_admin in jt.admin_role diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 177923b2bf2c..6c3e68c6c1d7 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -92,7 +92,7 @@ def test_team_accessible_by(team, user, project): u = user('team_member', False) team.member_role.children.add(project.use_role) - assert list(Project.accessible_objects(team.member_role, 'read_role')) == [project] + assert list(Project.accessible_objects(team, 'read_role')) == [project] assert u not in project.read_role team.member_role.members.add(u) @@ -104,7 +104,7 @@ def test_team_accessible_objects(team, user, project): u = user('team_member', False) team.member_role.children.add(project.use_role) - assert len(Project.accessible_objects(team.member_role, 'read_role')) == 1 + assert len(Project.accessible_objects(team, 'read_role')) == 1 assert not Project.accessible_objects(u, 'read_role') team.member_role.members.add(u) diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index 54a1cd57fe07..4b9a2b78ef46 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -4,7 +4,7 @@ from django.test import TransactionTestCase from awx.main.access import UserAccess, RoleAccess, TeamAccess -from awx.main.models import User, Organization, Inventory, Role +from awx.main.models import User, Organization, Inventory class TestSysAuditorTransactional(TransactionTestCase): @@ -18,7 +18,7 @@ def inventory(self): def test_auditor_caching(self): rando = self.rando() - with self.assertNumQueries(1): + with self.assertNumQueries(2): v = rando.is_system_auditor assert not v with self.assertNumQueries(0): @@ -153,34 +153,3 @@ def test_org_admin_cannot_delete_member_attached_to_other_group(org_admin, org_m access = UserAccess(org_admin) other_org.member_role.members.add(org_member) assert not access.can_delete(org_member) - - -@pytest.mark.parametrize('reverse', (True, False)) -@pytest.mark.django_db -def test_consistency_of_is_superuser_flag(reverse): - users = [User.objects.create(username='rando_{}'.format(i)) for i in range(2)] - for u in users: - assert u.is_superuser is False - - system_admin = Role.singleton('system_administrator') - if reverse: - for u in users: - u.roles.add(system_admin) - else: - system_admin.members.add(*[u.id for u in users]) # like .add(42, 54) - - for u in users: - u.refresh_from_db() - assert u.is_superuser is True - - users[0].roles.clear() - for u in users: - u.refresh_from_db() - assert users[0].is_superuser is False - assert users[1].is_superuser is True - - system_admin.members.clear() - - for u in users: - u.refresh_from_db() - assert u.is_superuser is False diff --git a/awx/main/tests/functional/test_teams.py b/awx/main/tests/functional/test_teams.py deleted file mode 100644 index eda57579cebd..000000000000 --- a/awx/main/tests/functional/test_teams.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - - -@pytest.mark.django_db() -def test_admin_not_member(team): - """Test to ensure we don't add admin_role as a parent to team.member_role, as - this creates a cycle with organization administration, which we've decided - to remove support for - - (2016-06-16) I think this might have been resolved. I'm asserting - this to be true in the mean time. - """ - - assert team.admin_role.is_ancestor_of(team.member_role) is True diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index b573d042b9ac..1cbaf63ca39a 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -355,6 +355,7 @@ 'ansible_base.rest_filters', 'ansible_base.jwt_consumer', 'ansible_base.resource_registry', + 'ansible_base.rbac', ] @@ -497,6 +498,12 @@ SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy' SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage' SOCIAL_AUTH_USER_MODEL = 'auth.User' +ROLE_SINGLETON_USER_RELATIONSHIP = '' +ROLE_SINGLETON_TEAM_RELATIONSHIP = '' + +# We want to short-circuit RBAC methods to get permission to system admins and auditors +ROLE_BYPASS_SUPERUSER_FLAGS = ['is_superuser'] +ROLE_BYPASS_ACTION_FLAGS = {'view': 'is_system_auditor'} _SOCIAL_AUTH_PIPELINE_BASE = ( 'social_core.pipeline.social_auth.social_details', @@ -1121,11 +1128,11 @@ ANSIBLE_BASE_TEAM_MODEL = 'main.Team' ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization' ANSIBLE_BASE_RESOURCE_CONFIG_MODULE = 'awx.resource_api' +ANSIBLE_BASE_PERMISSION_MODEL = 'main.Permission' from ansible_base.lib import dynamic_config # noqa: E402 -settings_file = os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py') -include(settings_file) +include(os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py')) # Add a postfix to the API URL patterns # example if set to '' API pattern will be /api @@ -1134,3 +1141,25 @@ # Use AWX base view, to give 401 on unauthenticated requests ANSIBLE_BASE_CUSTOM_VIEW_PARENT = 'awx.api.generics.APIView' + +# Settings for the ansible_base RBAC system + +# Settings for the RBAC system, override as necessary in app +ANSIBLE_BASE_ROLE_PRECREATE = { + 'object_admin': '{cls._meta.model_name}-admin', + 'org_admin': 'organization-admin', + 'org_children': 'organization-{cls._meta.model_name}-admin', + 'special': '{cls._meta.model_name}-{action}', +} + +# Use the new Gateway RBAC system for evaluations? You should. We will remove the old system soon. +ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED = True + +# Permissions a user will get when creating a new item +ANSIBLE_BASE_CREATOR_DEFAULTS = ['change', 'delete', 'execute', 'use', 'adhoc', 'approve', 'update', 'view'] + +# This is a stopgap, will delete after resource registry integration +ANSIBLE_BASE_SERVICE_PREFIX = "awx" + +# system username for django-ansible-base +SYSTEM_USERNAME = None diff --git a/awx/urls.py b/awx/urls.py index 2fcfc650f93a..7df216bda12d 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -2,7 +2,9 @@ # All Rights Reserved. from django.conf import settings -from django.urls import path, re_path, include +from django.urls import re_path, include, path + +from ansible_base.lib.dynamic_config.dynamic_urls import api_urls, api_version_urls, root_urls from ansible_base.resource_registry.urls import urlpatterns as resource_api_urls @@ -22,7 +24,10 @@ def get_urlpatterns(prefix=None): ] urlpatterns += [ - path(f'api{prefix}v2/', include(resource_api_urls)), + # path(f'api{prefix}v2/', include(resource_api_urls)), + path('api/v2/', include(api_version_urls)), + path('api/', include(api_urls)), + path('', include(root_urls)), re_path(r'^sso/', include('awx.sso.urls', namespace='sso')), re_path(r'^sso/', include('social_django.urls', namespace='social')), re_path(r'^(?:api/)?400.html$', handle_400), diff --git a/awx_collection/test/awx/test_role.py b/awx_collection/test/awx/test_role.py index f5cc5ceec1d4..519d1f87b077 100644 --- a/awx_collection/test/awx/test_role.py +++ b/awx_collection/test/awx/test_role.py @@ -18,9 +18,9 @@ def test_grant_organization_permission(run_module, admin_user, organization, sta assert not result.get('failed', False), result.get('msg', result) if state == 'present': - assert rando in organization.execute_role + assert rando in organization.admin_role else: - assert rando not in organization.execute_role + assert rando not in organization.admin_role @pytest.mark.django_db diff --git a/docs/rbac.md b/docs/rbac.md index bd9fdf7abf62..ef92ba61c2f5 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -1,166 +1,13 @@ # Role-Based Access Control (RBAC) -This document describes the RBAC implementation of the AWX Software. -The intended audience of this document is the AWX developer. +The Role-Based Access Control system has been moved to the django-ansible-base library. + +https://github.com/ansible/django-ansible-base ## Overview ### RBAC - System Basics -There are three main concepts to be familiar with: Roles, Resources, and Users. -Users can be members of a role, which gives them certain access to any -resources associated with that role, or any resources associated with "descendent" -roles. - -For example, if I have an organization named "MyCompany" and I want to allow -two people, "Alice", and "Bob", access to manage all of the settings associated -with that organization, I'd make them both members of the organization's `admin_role`. - -It is often the case that you have many Roles in a system, and you want some -roles to include all of the capabilities of other roles. For example, you may -want a System Administrator to have access to everything that an Organization -Administrator has access to, who has everything that a Project Administrator -has access to, and so on. We refer to this concept as the 'Role Hierarchy', and -is represented by allowing roles to have "Parent Roles". Any permission that a -role has is implicitly granted to any parent roles (or parents of those -parents, and so on). Of course roles can have more than one parent, and -capabilities are implicitly granted to all parents. (Technically speaking, this -forms a directional acyclic graph instead of a strict hierarchy, but the -concept should remain intuitive.) +Illustrations from the old RBAC system, before the move to django-ansible-base. ![Example RBAC hierarchy](img/rbac_example.png?raw=true) - - -### Implementation Overview - -The RBAC system allows you to create and layer roles for controlling access to resources. Any Django Model can -be made into a resource in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource, you can -extend the model definition to have specific roles using the `ImplicitRoleField`. Within the declaration of -this role field you can also specify any parents the role may have, and the RBAC system will take care of -all of the appropriate ancestral binding that takes place behind the scenes to ensure that the model you've declared -is kept up to date as the relations in your model change. - -### Roles - -Roles are defined for a resource. If a role has any parents, these parents will be considered when determining -what roles are checked when accessing a resource. - - ResourceA - |-- AdminRole - - ResourceB - | -- AdminRole - |-- parent = ResourceA.AdminRole - -When a user attempts to access ResourceB, we will check for their access using the set of all unique roles, including the parents. - - ResourceA.AdminRole, ResourceB.AdminRole - -This would provide any members of the above roles with access to ResourceB. - -#### Singleton Role - -There is a special case _Singleton Role_ that you can create. This type of role is for system-wide roles. - -### Models - -The RBAC system defines a few new models. These models represent the underlying RBAC implementation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins. - -#### `Role` - -`Role` defines a single role within the RBAC implementation. It encapsulates the `ancestors`, `parents`, and `members` for a role. This model is intentionally kept dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. - -##### `visible_roles(cls, user)` - -`visible_roles` is a class method that will look up all of the `Role` instances a user can "see". This includes any roles the user is a direct descendent of as well as any ancestor roles. - -##### `singleton(cls, name)` - -The `singleton` class method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return the new role in the case it does not. - -##### `get_absolute_url(self)` - -`get_absolute_url` returns the consumable URL endpoint for the `Role`. - -##### `rebuild_role_ancestor_list(self)` - -`rebuild_role_ancestor_list` will rebuild the current role ancestry that is stored in the `ancestors` field of a `Role`. This is called for you by `save` and different Django signals. - -##### `is_ancestor_of(self, role)` - -`is_ancestor_of` returns if the given `role` is an ancestor of the current `Role` instance. - -##### `user in role` - -You may use the `user in some_role` syntax to check and see if the specified -user is a member of the given role, **or** a member of any ancestor role. - -### Fields - -#### `ImplicitRoleField` - -`ImplicitRoleField` fields are declared on your model. They provide the definition of grantable roles for accessing your resource. You may (and should) use the `parent_role` parameter to specify any parent roles that should inherit privileges implied by the role. - -`parent_role` is the link to any parent roles you want considered when a user -is requesting access to your resource. A `parent_role` can be declared as a -single string, `"parent.read_role"`, or a list of many roles, -`['parentA.read_role', 'parentB.read_role']` which will make each listed role a parent. You can also use the syntax -`[('parentA.read_role', 'parentB.read_role'), 'parentC.read_role']` to make -`(parentA.read_role OR parentB.read_role) AND 'parentC.read_role` parents (so `parentB.read_role` will be added only if `parentA.read_role` was `None`). -If any listed role can't be evaluated (for example if there are `None` components in the path), then they are simply ignored until the value of the field changes. - - -### Mixins - -#### `ResourceMixin` - -By mixing in the `ResourceMixin` to your model, you are turning your model in to a resource in the eyes of the RBAC implementation. Your model will gain the helper methods that aid in the checking the access a users roles provides them to your resource. - -##### `accessible_objects(cls, user, role_field)` - -`accessible_objects` is a class method to use instead of `Model.objects`. This method will restrict the query of objects to only those that the user has access to - specifically those objects which the user is a member of the specified role (either directly or indirectly). - -```python - objects = MyModel.accessible_objects(user, 'admin_role') - objects.filter(name__istartswith='december') -``` - -##### `accessible_pk_qs(cls, user, role_field)` - -`accessible_pk_qs` returns a queryset of ids that match the same role filter as `accessible_objects`. -A key difference is that this is more performant to use in subqueries when filtering related models. - -Say that another model, `YourModel` has a ForeignKey reference to `MyModel` via a field `my_model`, -and you want to return all instances of `YourModel` that have a visible related `MyModel`. -The best way to do this is: - -```python - YourModel.filter(my_model=MyModel.accessible_pk_qs(user, 'admin_role')) -``` - -## Usage - -After exploring the _Overview_, the usage of the RBAC implementation in your code should feel unobtrusive and natural. - -```python - # make your model a Resource - class Document(Model, ResourceMixin): - ... - # declare your new role - readonly_role = ImplicitRoleField() -``` - -Now that your model is a resource and has a `Role` defined, you can begin to access the helper methods provided to you by the `ResourceMixin` for checking a user's access to your resource. Here is the output of a Python REPL session: - -```python - # we've created some documents and a user - >>> document = Document.objects.filter(pk=1) - >>> user = User.objects.first() - >>> user in document.readonly_role - False # not accessible by default - >>> document.readonly_role.members.add(user) - >>> user in document.readonly_role - True # now it is accessible - >>> user in document.readonly_role - False # my role does not have admin permission -``` diff --git a/requirements/requirements_git.txt b/requirements/requirements_git.txt index 350c2d48b095..ebab0a405716 100644 --- a/requirements/requirements_git.txt +++ b/requirements/requirements_git.txt @@ -5,4 +5,4 @@ git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner # specifically need https://github.com/robgolding/django-radius/pull/27 git+https://github.com/ansible/django-radius.git@develop#egg=django-radius git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml -django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry] +django-ansible-base @ git+https://github.com/alancoding/django-ansible-base@django_permissions#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac] From 51a526c00f7d07504f37f37f767651342f45c1a3 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 20 Feb 2024 22:24:41 -0500 Subject: [PATCH 02/20] Cast ObjectRole object_id to int, very wrong, tmp fix --- awx/main/models/mixins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 9d224876f49c..fd1334063247 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models.query import QuerySet +from django.db.models.functions import Cast from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ @@ -77,7 +78,8 @@ def _accessible_pk_qs(cls, accessor, role_field, content_types=None): return ( ObjectRole.objects.filter(role_definition__permissions__codename=codename, content_type=ContentType.objects.get_for_model(cls)) - .values_list('object_id') + .annotate(int_object_id=Cast('object_id', models.IntegerField())) + .values_list('int_object_id') .distinct() ) return cls.access_ids_qs(accessor, to_permissions[role_field], content_types=content_types) From ef20666856aa5688bb48c6f204ffe2bf6ea71ed7 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Sat, 24 Feb 2024 22:23:39 -0500 Subject: [PATCH 03/20] Cache organization child evaluations and remove hacks --- awx/main/access.py | 10 +---- awx/main/models/mixins.py | 16 +++----- awx/main/models/rbac.py | 5 +-- .../dab_rbac/test_translation_layer.py | 38 +++++++++++++++++++ awx/settings/defaults.py | 3 ++ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 8a483b0881dc..f12bd824a507 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1805,15 +1805,7 @@ def can_start(self, obj, validate_license=True): return True # Standard permissions model without job template involved - # NOTE: this is the best we can do without caching way more permissions - from django.contrib.contenttypes.models import ContentType - - filter_kwargs = dict( - content_type_id=ContentType.objects.get_for_model(Organization), - object_id=obj.organization_id, - role_definition__permissions__codename='execute_jobtemplate', - ) - if self.user.has_roles.filter(**filter_kwargs).exists(): + if obj.organization and self.user in obj.organization.execute_role: return True elif not (obj.job_template or obj.organization): raise PermissionDenied(_('Job has been orphaned from its job template and organization.')) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index fd1334063247..5df78e15b6d6 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -13,7 +13,6 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models.query import QuerySet -from django.db.models.functions import Cast from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ @@ -67,21 +66,16 @@ def accessible_pk_qs(cls, accessor, role_field): @staticmethod def _accessible_pk_qs(cls, accessor, role_field, content_types=None): if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: - if role_field not in to_permissions and cls._meta.model_name == 'organization': - # superficial alternative for narrow exceptions with org roles - # I think this mostly applies to organization members, which is not fully defined yet + if cls._meta.model_name == 'organization' and role_field in org_role_to_permission: + # Organization roles can not use the DAB RBAC shortcuts + # like Organization.access_qs(user, 'change_jobtemplate') is needed + # not just Organization.access_qs(user, 'change') is needed if accessor.is_superuser: return cls.objects.values_list('id') - from ansible_base.rbac.models import ObjectRole codename = org_role_to_permission[role_field] - return ( - ObjectRole.objects.filter(role_definition__permissions__codename=codename, content_type=ContentType.objects.get_for_model(cls)) - .annotate(int_object_id=Cast('object_id', models.IntegerField())) - .values_list('int_object_id') - .distinct() - ) + return cls.access_ids_qs(accessor, codename, content_types=content_types) return cls.access_ids_qs(accessor, to_permissions[role_field], content_types=content_types) if accessor._meta.model_name == 'user': ancestor_roles = accessor.roles.all() diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 3cfd813948cb..92aac71cd21d 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -192,10 +192,7 @@ def __contains__(self, accessor): return True if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: - if self.role_field not in to_permissions and self.content_object and self.content_object._meta.model_name == 'organization': - # valid alternative for narrow exceptions with org roles - if self.role_field not in org_role_to_permission: - raise Exception(f'org {self.role_field} evaluated but not a translatable permission') + if self.content_object and self.content_object._meta.model_name == 'organization' and self.role_field in org_role_to_permission: codename = org_role_to_permission[self.role_field] return accessor.has_obj_perm(self.content_object, codename) 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 7303fe0ae7bd..e17788b98b81 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -1,6 +1,7 @@ import pytest from awx.main.models.rbac import get_role_from_object_role +from awx.main.models import User, Organization from ansible_base.rbac.models import RoleUserAssignment @@ -21,3 +22,40 @@ def test_round_trip_roles(organization, rando, role_name): print(assignment.role_definition.name) old_role = get_role_from_object_role(assignment.object_role) assert old_role.id == getattr(organization, role_name).id + + +@pytest.mark.django_db +def test_organization_level_permissions(organization, inventory): + u1 = User.objects.create(username='alice') + u2 = User.objects.create(username='bob') + + organization.inventory_admin_role.members.add(u1) + organization.workflow_admin_role.members.add(u2) + + assert u1 in inventory.admin_role + assert u1 in organization.inventory_admin_role + assert u2 in organization.workflow_admin_role + + assert u2 not in organization.inventory_admin_role + assert u1 not in organization.workflow_admin_role + assert not (set(u1.has_roles.all()) & set(u2.has_roles.all())) # user have no roles in common + + # Old style + assert set(Organization.accessible_objects(u1, 'inventory_admin_role')) == set([organization]) + assert set(Organization.accessible_objects(u2, 'inventory_admin_role')) == set() + assert set(Organization.accessible_objects(u1, 'workflow_admin_role')) == set() + assert set(Organization.accessible_objects(u2, 'workflow_admin_role')) == set([organization]) + + # New style + assert set(Organization.access_qs(u1, 'add_inventory')) == set([organization]) + assert set(Organization.access_qs(u1, 'change_inventory')) == set([organization]) + assert set(Organization.access_qs(u2, 'add_inventory')) == set() + assert set(Organization.access_qs(u1, 'add_workflowjobtemplate')) == set() + assert set(Organization.access_qs(u2, 'add_workflowjobtemplate')) == set([organization]) + + +@pytest.mark.django_db +def test_organization_execute_role(organization, rando): + organization.execute_role.members.add(rando) + assert rando in organization.execute_role + assert set(Organization.accessible_objects(rando, 'execute_role')) == set([organization]) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 1cbaf63ca39a..556d899e96a8 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1161,5 +1161,8 @@ # This is a stopgap, will delete after resource registry integration ANSIBLE_BASE_SERVICE_PREFIX = "awx" +# Temporary, for old roles API compatibility, save child permissions at organization level +ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True + # system username for django-ansible-base SYSTEM_USERNAME = None From f8b5c3909fb59a7244bf167d0d4225ea36fb1e98 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 6 Mar 2024 13:45:20 -0500 Subject: [PATCH 04/20] Bump number of allowed endpoints (#14956) --- awx/main/tests/docs/test_swagger_generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tests/docs/test_swagger_generation.py b/awx/main/tests/docs/test_swagger_generation.py index b480e4562ee6..f05435c1e37b 100644 --- a/awx/main/tests/docs/test_swagger_generation.py +++ b/awx/main/tests/docs/test_swagger_generation.py @@ -99,7 +99,7 @@ def test_sanity(self, release, request): # The number of API endpoints changes over time, but let's just check # for a reasonable number here; if this test starts failing, raise/lower the bounds paths = JSON['paths'] - assert 250 < len(paths) < 375 + assert 250 < len(paths) < 400 assert set(list(paths['/api/'].keys())) == set(['get', 'parameters']) assert set(list(paths['/api/v2/'].keys())) == set(['get', 'parameters']) assert set(list(sorted(paths['/api/v2/credentials/'].keys()))) == set(['get', 'post', 'parameters']) From 05bd77749ef7cb7bef0adb92e316f7e7e1ae09fb Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Mon, 11 Mar 2024 12:16:49 -0400 Subject: [PATCH 05/20] [DAB RBAC] Re-implement system auditor as a singleton role in new system (#14963) * Add new enablement settings from DAB RBAC * Initial implementation of system auditor as role without testing * Fix system auditor role, remove duplicate assignments * Make the system auditor role managed * Flake8 fix * Remove another thing from old solution * Fix a few test failures * Add extra setting to disable custom system roles via API * Add test for custom role prohibition --- awx/api/generics.py | 1 - awx/api/serializers.py | 1 - awx/api/views/__init__.py | 10 +------ awx/main/access.py | 1 - .../migrations/0190_add_django_permissions.py | 1 + ...2_custom_roles.py => 0191_custom_roles.py} | 2 +- .../0191_profile_is_system_auditor.py | 20 -------------- awx/main/migrations/_dab_rbac.py | 20 +++++++++++--- awx/main/models/__init__.py | 27 ++++++++++++++----- awx/main/models/organization.py | 1 - awx/main/models/rbac.py | 13 ++++++++- .../functional/dab_rbac/test_dab_rbac_api.py | 7 +++++ .../functional/test_rbac_job_templates.py | 2 +- awx/main/tests/functional/test_rbac_user.py | 3 ++- awx/settings/defaults.py | 8 ++++++ 15 files changed, 70 insertions(+), 47 deletions(-) rename awx/main/migrations/{0192_custom_roles.py => 0191_custom_roles.py} (94%) delete mode 100644 awx/main/migrations/0191_profile_is_system_auditor.py diff --git a/awx/api/generics.py b/awx/api/generics.py index 5b68bb419574..a0c609db26fa 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -971,7 +971,6 @@ def post(self, request, *args, **kwargs): None, None, self.model, obj, request.user, create_kwargs=create_kwargs, copy_name=serializer.validated_data.get('name', '') ) if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all(): - new_obj.admin_role.members.add(request.user) give_creator_permissions(request.user, new_obj) if sub_objs: permission_check_func = None diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2423862d2497..c0577324c2a1 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3142,7 +3142,6 @@ def create(self, validated_data): credential = super(CredentialSerializerCreate, self).create(validated_data) if user: - credential.admin_role.members.add(user) give_creator_permissions(user, credential) if team: if not credential.organization or team.organization.id != credential.organization.id: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index fba3260e4c71..944f982a4d66 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -90,7 +90,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.versioning import reverse from awx.main import models -from awx.main.models.rbac import give_creator_permissions, get_role_definition +from awx.main.models.rbac import get_role_definition from awx.main.utils import ( camelcase_to_underscore, extract_ansible_vars, @@ -2314,14 +2314,6 @@ class JobTemplateList(ListCreateAPIView): serializer_class = serializers.JobTemplateSerializer always_allow_superuser = False - def post(self, request, *args, **kwargs): - ret = super(JobTemplateList, self).post(request, *args, **kwargs) - if ret.status_code == 201: - job_template = models.JobTemplate.objects.get(id=ret.data['id']) - job_template.admin_role.members.add(request.user) - give_creator_permissions(request.user, job_template) - return ret - class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = models.JobTemplate diff --git a/awx/main/access.py b/awx/main/access.py index f12bd824a507..505c1de918bd 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -652,7 +652,6 @@ def filtered_queryset(self): User.objects.filter(pk__in=Organization.accessible_objects(self.user, 'read_role').values('member_role__members')) | User.objects.filter(pk=self.user.id) | User.objects.filter(is_superuser=True) - | User.objects.filter(profile__is_system_auditor=True) ).distinct() return qs diff --git a/awx/main/migrations/0190_add_django_permissions.py b/awx/main/migrations/0190_add_django_permissions.py index 39202cdd2ec3..540c1c6b407d 100644 --- a/awx/main/migrations/0190_add_django_permissions.py +++ b/awx/main/migrations/0190_add_django_permissions.py @@ -7,6 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('main', '0189_inbound_hop_nodes'), + ('dab_rbac', '__first__'), ] operations = [ diff --git a/awx/main/migrations/0192_custom_roles.py b/awx/main/migrations/0191_custom_roles.py similarity index 94% rename from awx/main/migrations/0192_custom_roles.py rename to awx/main/migrations/0191_custom_roles.py index ce6b7ba9e156..8a203bb75cb7 100644 --- a/awx/main/migrations/0192_custom_roles.py +++ b/awx/main/migrations/0191_custom_roles.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('main', '0191_profile_is_system_auditor'), + ('main', '0190_add_django_permissions'), ('dab_rbac', '__first__'), ] diff --git a/awx/main/migrations/0191_profile_is_system_auditor.py b/awx/main/migrations/0191_profile_is_system_auditor.py deleted file mode 100644 index a7f7c18813ad..000000000000 --- a/awx/main/migrations/0191_profile_is_system_auditor.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.6 on 2023-11-20 16:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('main', '0190_add_django_permissions'), - ] - run_before = [ - ('dab_rbac', '__first__'), - ] - - operations = [ - migrations.AddField( - model_name='profile', - name='is_system_auditor', - field=models.BooleanField(default=False, help_text='Can view everying in the system, proxies to User model'), - ), - ] diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 18c87697a9f5..1920653186d6 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -144,6 +144,7 @@ def migrate_to_new_rbac(apps, schema_editor): """ Role = apps.get_model('main', 'Role') RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') + RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment') Permission = apps.get_model('auth', 'Permission') migration_time = now() @@ -224,14 +225,25 @@ def migrate_to_new_rbac(apps, schema_editor): content_type_id=role.content_type_id, ) + # Create new replacement system auditor role + new_system_auditor, created = RoleDefinition.objects.get_or_create( + name='System Auditor', + defaults={ + 'description': 'Migrated singleton role giving read permission to everything', + 'managed': True, + 'created_on': migration_time, + 'modified_on': migration_time, + }, + ) + new_system_auditor.permissions.add(*list(Permission.objects.filter(codename__startswith='view'))) + # migrate is_system_auditor flag, because it is no longer handled by a system role - role = Role.objects.filter(singleton_name='system_auditor').first() - if role: + old_system_auditor = Role.objects.filter(singleton_name='system_auditor').first() + if old_system_auditor: # if the system auditor role is not present, this is a new install and no users should exist ct = 0 for user in role.members.all(): - user.profile.is_system_auditor = True - user.profile.save(update_fields=['is_system_auditor']) + RoleUserAssignment.objects.create(user=user, role_definition=new_system_auditor) ct += 1 if ct: logger.info(f'Migrated {ct} users to new system auditor flag') diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index cf16b9f258f5..dce3f371d08b 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -11,6 +11,7 @@ # django-ansible-base from ansible_base.resource_registry.fields import AnsibleResourceField from ansible_base.rbac import permission_registry +from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment from ansible_base.lib.utils.models import prevent_search from ansible_base.lib.utils.models import user_summary_fields @@ -199,11 +200,21 @@ def created(user): User.add_to_class('created', created) +def get_system_auditor_role(): + rd, created = RoleDefinition.objects.get_or_create( + name='System Auditor', defaults={'description': 'Migrated singleton role giving read permission to everything'} + ) + if created: + rd.permissions.add(*list(permission_registry.permission_qs.filter(codename__startswith='view'))) + return rd + + @property def user_is_system_auditor(user): if not hasattr(user, '_is_system_auditor'): if user.pk: - user._is_system_auditor = user.profile.is_system_auditor + rd = get_system_auditor_role() + user._is_system_auditor = RoleUserAssignment.objects.filter(user=user, role_definition=rd).exists() else: # Odd case where user is unsaved, this should never be relied on return False @@ -217,11 +228,15 @@ def user_is_system_auditor(user, tf): # time they've logged in, and we've just created the new User in this # request), we need one to set up the system auditor role user.save() - if user.profile.is_system_auditor != bool(tf): - prior_value = user.profile.is_system_auditor - user.profile.is_system_auditor = bool(tf) - user.profile.save(update_fields=['is_system_auditor']) - user._is_system_auditor = user.profile.is_system_auditor + rd = get_system_auditor_role() + assignment = RoleUserAssignment.objects.filter(user=user, role_definition=rd).first() + prior_value = bool(assignment) + if prior_value != bool(tf): + if assignment: + assignment.delete() + else: + rd.give_global_permission(user) + user._is_system_auditor = bool(tf) entry = ActivityStream.objects.create(changes=json.dumps({"is_system_auditor": [prior_value, bool(tf)]}), object1='user', operation='update') entry.user.add(user) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index c34dacdde515..e543a91f8035 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -182,7 +182,6 @@ class Meta: max_length=1024, default='', ) - is_system_auditor = models.BooleanField(default=False, help_text=_('Can view everying in the system, proxies to User model')) class UserSessionMembership(BaseModel): diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 92aac71cd21d..d99f52f4dded 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -7,6 +7,9 @@ import contextlib import re +# django-rest-framework +from rest_framework.serializers import ValidationError + # Django from django.db import models, transaction, connection from django.db.models.signals import m2m_changed @@ -552,7 +555,15 @@ def get_role_definition(role): action_name = f.name.rsplit("_", 1)[0] rd_name = f'{obj._meta.model_name}-{action_name}-compat' perm_list = get_role_codenames(role) - rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults={'content_type_id': role.content_type_id}) + defaults = {'content_type_id': role.content_type_id} + try: + rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults) + except ValidationError: + # This is a tricky case - practically speaking, users should not be allowed to create team roles + # or roles that include the team member permission. + # If we need to create this for compatibility purposes then we will create it as a managed non-editable role + defaults['managed'] = True + rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults) return rd 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 00976f2d2aea..7db61ae04efa 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 @@ -32,6 +32,13 @@ def test_custom_read_role(admin_user, post): assert rd.content_type == ContentType.objects.get_for_model(Inventory) +@pytest.mark.django_db +def test_custom_system_roles_prohibited(admin_user, post): + rd_url = django_reverse('roledefinition-list') + resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['view_inventory']}, user=admin_user, expect=400) + assert 'System-wide roles are not enabled' in str(resp.data) + + @pytest.mark.django_db def test_assign_managed_role(admin_user, alice, rando, inventory, post): rd = RoleDefinition.objects.get(name='inventory-admin') diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index 5af5c9707bb8..17e7ff3524b0 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -177,7 +177,7 @@ def test_job_template_creator_access(project, organization, rando, post): jt_pk = response.data['id'] jt_obj = JobTemplate.objects.get(pk=jt_pk) # Creating a JT should place the creator in the admin role - assert rando in jt_obj.admin_role.members.all() + assert rando in jt_obj.admin_role @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index 4b9a2b78ef46..10ca851bbe57 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -4,7 +4,7 @@ from django.test import TransactionTestCase from awx.main.access import UserAccess, RoleAccess, TeamAccess -from awx.main.models import User, Organization, Inventory +from awx.main.models import User, Organization, Inventory, get_system_auditor_role class TestSysAuditorTransactional(TransactionTestCase): @@ -18,6 +18,7 @@ def inventory(self): def test_auditor_caching(self): rando = self.rando() + get_system_auditor_role() # pre-create role, normally done by migrations with self.assertNumQueries(2): v = rando.is_system_auditor assert not v diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 556d899e96a8..98a5d69212ae 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1164,5 +1164,13 @@ # Temporary, for old roles API compatibility, save child permissions at organization level ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True +# Currently features are enabled to keep compatibility with old system, except custom roles +ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN = False +# ANSIBLE_BASE_ALLOW_CUSTOM_ROLES = True +ANSIBLE_BASE_ALLOW_CUSTOM_TEAM_ROLES = False +ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES = True +ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = False # System auditor has always been restricted to users +ANSIBLE_BASE_ALLOW_SINGLETON_ROLES_API = False # Do not allow creating user-defined system-wide roles + # system username for django-ansible-base SYSTEM_USERNAME = None From 6a22e7369561e40961b29fb9eb7c33b2404f609e Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 13 Mar 2024 10:12:24 -0400 Subject: [PATCH 06/20] Use AWX base view to make unauth requests 401 (#14981) --- awx/settings/defaults.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 98a5d69212ae..f94b4af42807 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -880,6 +880,7 @@ 'loggers': { 'django': {'handlers': ['console']}, 'django.request': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'WARNING'}, + 'ansible_base': {'handlers': ['console', 'file', 'tower_warnings']}, 'daphne': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'INFO'}, 'rest_framework.request': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'WARNING', 'propagate': False}, 'py.warnings': {'handlers': ['console']}, @@ -1174,3 +1175,6 @@ # system username for django-ansible-base SYSTEM_USERNAME = None + +# Use AWX base view, to give 401 on unauthenticated requests +ANSIBLE_BASE_CUSTOM_VIEW_PARENT = 'awx.api.generics.APIView' From 2db2cad2797d1a5122ec1fdb63281712c3a06d46 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 13 Mar 2024 10:13:05 -0400 Subject: [PATCH 07/20] Minor RBAC test fix (#14982) --- awx/main/tests/functional/test_rbac_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index ac0179d7b890..e1e76e981e01 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -5,6 +5,8 @@ from awx.api.versioning import reverse from awx.main.models.rbac import Role +from django.test.utils import override_settings + @pytest.fixture def role(): @@ -185,6 +187,7 @@ def test_remove_role_from_user(role, post, admin): @pytest.mark.django_db +@override_settings(ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN=True) def test_get_teams_roles_list(get, team, organization, admin): team.member_role.children.add(organization.admin_role) url = reverse('api:team_roles_list', kwargs={'pk': team.id}) From fc5280c2912f79bdb14f35a57677e31b4d30b845 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Thu, 14 Mar 2024 10:05:39 -0400 Subject: [PATCH 08/20] Adopt internal DAB RBAC Permission model (#14994) --- .../migrations/0190_add_django_permissions.py | 15 -------------- awx/main/migrations/_dab_rbac.py | 9 +++++---- awx/main/models/organization.py | 20 ------------------- 3 files changed, 5 insertions(+), 39 deletions(-) diff --git a/awx/main/migrations/0190_add_django_permissions.py b/awx/main/migrations/0190_add_django_permissions.py index 540c1c6b407d..d8e3406e2495 100644 --- a/awx/main/migrations/0190_add_django_permissions.py +++ b/awx/main/migrations/0190_add_django_permissions.py @@ -83,19 +83,4 @@ class Migration(migrations.Migration): 'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')], }, ), - migrations.CreateModel( - name='DABPermission', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='name')), - ('codename', models.CharField(max_length=100, verbose_name='codename')), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='content type')), - ], - options={ - 'verbose_name': 'permission', - 'verbose_name_plural': 'permissions', - 'ordering': ['content_type__model', 'codename'], - 'unique_together': {('content_type', 'codename')}, - }, - ), ] diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 1920653186d6..10d78351cd32 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -4,7 +4,8 @@ from django.apps import apps as global_apps from django.db.models import ForeignKey from django.utils.timezone import now -from ansible_base.rbac.migrations._utils import give_permissions, create_custom_permissions +from ansible_base.rbac.migrations._utils import give_permissions +from ansible_base.rbac.management import create_dab_permissions from awx.main.fields import ImplicitRoleField from awx.main.constants import role_name_to_perm_mapping @@ -14,7 +15,7 @@ def create_permissions_as_operation(apps, schema_editor): - create_custom_permissions(global_apps.get_app_config("main")) + create_dab_permissions(global_apps.get_app_config("main"), apps=apps) """ @@ -108,7 +109,7 @@ def get_descendents(f, children_map): def get_permissions_for_role(role_field, children_map, apps): - Permission = apps.get_model('auth', 'Permission') + Permission = apps.get_model('dab_rbac', 'DABPermission') ContentType = apps.get_model('contenttypes', 'ContentType') perm_list = [] @@ -145,7 +146,7 @@ def migrate_to_new_rbac(apps, schema_editor): Role = apps.get_model('main', 'Role') RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment') - Permission = apps.get_model('auth', 'Permission') + Permission = apps.get_model('dab_rbac', 'DABPermission') migration_time = now() # remove add premissions that are not valid for migrations from old versions diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index e543a91f8035..8ff422364d8b 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -216,23 +216,3 @@ def user_get_absolute_url(user, request=None): return reverse('api:user_detail', kwargs={'pk': user.pk}, request=request) User.add_to_class('get_absolute_url', user_get_absolute_url) - - -class DABPermission(models.Model): - """ - This is a partial copy of auth.Permission to be used by DAB RBAC lib - and in order to be consistent with other applications - """ - - name = models.CharField("name", max_length=255) - content_type = models.ForeignKey(ContentType, models.CASCADE, verbose_name="content type") - codename = models.CharField("codename", max_length=100) - - class Meta: - verbose_name = "permission" - verbose_name_plural = "permissions" - unique_together = [["content_type", "codename"]] - ordering = ["content_type__model", "codename"] - - def __str__(self): - return f"<{self.__class__.__name__}: {self.codename}>" From c176eefa0646b0658ef9ba6c1f27a9d38ee8fe42 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Thu, 14 Mar 2024 11:57:32 -0400 Subject: [PATCH 09/20] Bump migration number for RBAC branch --- ...add_django_permissions.py => 0191_add_django_permissions.py} | 2 +- .../migrations/{0191_custom_roles.py => 0192_custom_roles.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename awx/main/migrations/{0190_add_django_permissions.py => 0191_add_django_permissions.py} (98%) rename awx/main/migrations/{0191_custom_roles.py => 0192_custom_roles.py} (94%) diff --git a/awx/main/migrations/0190_add_django_permissions.py b/awx/main/migrations/0191_add_django_permissions.py similarity index 98% rename from awx/main/migrations/0190_add_django_permissions.py rename to awx/main/migrations/0191_add_django_permissions.py index d8e3406e2495..5cbb93956044 100644 --- a/awx/main/migrations/0190_add_django_permissions.py +++ b/awx/main/migrations/0191_add_django_permissions.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('main', '0189_inbound_hop_nodes'), + ('main', '0190_alter_inventorysource_source_and_more'), ('dab_rbac', '__first__'), ] diff --git a/awx/main/migrations/0191_custom_roles.py b/awx/main/migrations/0192_custom_roles.py similarity index 94% rename from awx/main/migrations/0191_custom_roles.py rename to awx/main/migrations/0192_custom_roles.py index 8a203bb75cb7..3491ad67e14a 100644 --- a/awx/main/migrations/0191_custom_roles.py +++ b/awx/main/migrations/0192_custom_roles.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('main', '0190_add_django_permissions'), + ('main', '0191_add_django_permissions'), ('dab_rbac', '__first__'), ] From 9638458f3b0d7e7a6f2442a661bda43a87a1fd21 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Thu, 14 Mar 2024 15:43:14 -0400 Subject: [PATCH 10/20] [RBAC] Fix migration for created and modified field changes (#14999) Fix migration for created and modified field changes --- awx/main/migrations/_dab_rbac.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 10d78351cd32..1d8da9222fac 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -3,7 +3,6 @@ from django.apps import apps as global_apps from django.db.models import ForeignKey -from django.utils.timezone import now from ansible_base.rbac.migrations._utils import give_permissions from ansible_base.rbac.management import create_dab_permissions @@ -147,7 +146,6 @@ def migrate_to_new_rbac(apps, schema_editor): RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment') Permission = apps.get_model('dab_rbac', 'DABPermission') - migration_time = now() # remove add premissions that are not valid for migrations from old versions for perm_str in ('add_organization', 'add_jobtemplate'): @@ -209,7 +207,7 @@ def migrate_to_new_rbac(apps, schema_editor): role_definition, created = RoleDefinition.objects.get_or_create( name=role_definition_name, - defaults={'description': description, 'content_type_id': role.content_type_id, 'created_on': migration_time, 'modified_on': migration_time}, + defaults={'description': description, 'content_type_id': role.content_type_id}, ) if created: @@ -229,12 +227,7 @@ def migrate_to_new_rbac(apps, schema_editor): # Create new replacement system auditor role new_system_auditor, created = RoleDefinition.objects.get_or_create( name='System Auditor', - defaults={ - 'description': 'Migrated singleton role giving read permission to everything', - 'managed': True, - 'created_on': migration_time, - 'modified_on': migration_time, - }, + defaults={'description': 'Migrated singleton role giving read permission to everything', 'managed': True}, ) new_system_auditor.permissions.add(*list(Permission.objects.filter(codename__startswith='view'))) From 1fc201a1aa48e48f4c45cbaf4e14bc8702410b23 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 15 Mar 2024 12:54:03 -0400 Subject: [PATCH 11/20] [RBAC] Fix server error from delete capability of approvals (#15002) Fix server error from delete capability of approvals --- awx/main/access.py | 3 +++ .../dab_rbac/test_translation_layer.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/awx/main/access.py b/awx/main/access.py index 505c1de918bd..dec9386d5d88 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2940,6 +2940,9 @@ def can_approve_or_deny(self, obj): if (obj.workflow_job_template and self.user in obj.workflow_job_template.approval_role) or self.user.is_superuser: return True + def can_delete(self, obj): + return self.user.is_superuser # Not really supposed to be done + class WorkflowApprovalTemplateAccess(BaseAccess): """ 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 e17788b98b81..8d55c6e3ce40 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -1,7 +1,8 @@ import pytest from awx.main.models.rbac import get_role_from_object_role -from awx.main.models import User, Organization +from awx.main.models import User, Organization, WorkflowJobTemplate, WorkflowJobTemplateNode +from awx.api.versioning import reverse from ansible_base.rbac.models import RoleUserAssignment @@ -59,3 +60,17 @@ def test_organization_execute_role(organization, rando): organization.execute_role.members.add(rando) assert rando in organization.execute_role assert set(Organization.accessible_objects(rando, 'execute_role')) == set([organization]) + + +@pytest.mark.django_db +def test_workflow_approval_list(get, post, admin_user): + workflow_job_template = WorkflowJobTemplate.objects.create() + approval_node = WorkflowJobTemplateNode.objects.create(workflow_job_template=workflow_job_template) + url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, user=admin_user) + approval_node.refresh_from_db() + approval_jt = approval_node.unified_job_template + approval_jt.create_unified_job() + + r = get(url=reverse('api:workflow_approval_list'), user=admin_user, expect=200) + assert r.data['count'] >= 1 From 622fcfa919ed726d729d019d3d09816d61cbcbda Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 19 Mar 2024 13:29:34 -0400 Subject: [PATCH 12/20] Generalize can_delete solution, use devel DAB (#15009) * Generalize can_delete solution, use devel DAB * Fix bug where model was used instead of model_name * Linter fixes --- awx/main/access.py | 10 ++++++---- awx/main/migrations/0191_add_django_permissions.py | 3 +-- awx/main/models/organization.py | 1 - requirements/requirements_git.txt | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index dec9386d5d88..f89d05cd2b33 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -23,6 +23,7 @@ # django-ansible-base from ansible_base.lib.utils.validation import to_python_boolean from ansible_base.rbac.models import RoleEvaluation +from ansible_base.rbac import permission_registry # AWX from awx.main.utils import ( @@ -264,7 +265,11 @@ def can_admin(self, obj, data): return self.can_change(obj, data) def can_delete(self, obj): - return self.user.has_obj_perm(obj, 'delete') + if self.user.is_superuser: + return True + if obj._meta.model_name in [cls._meta.model_name for cls in permission_registry.all_registered_models]: + return self.user.has_obj_perm(obj, 'delete') + return False def can_copy(self, obj): return self.can_add({'reference_obj': obj}) @@ -2940,9 +2945,6 @@ def can_approve_or_deny(self, obj): if (obj.workflow_job_template and self.user in obj.workflow_job_template.approval_role) or self.user.is_superuser: return True - def can_delete(self, obj): - return self.user.is_superuser # Not really supposed to be done - class WorkflowApprovalTemplateAccess(BaseAccess): """ diff --git a/awx/main/migrations/0191_add_django_permissions.py b/awx/main/migrations/0191_add_django_permissions.py index 5cbb93956044..a3074e45ab68 100644 --- a/awx/main/migrations/0191_add_django_permissions.py +++ b/awx/main/migrations/0191_add_django_permissions.py @@ -1,7 +1,6 @@ # Generated by Django 4.2.6 on 2023-11-13 20:10 -from django.db import migrations, models -import django.db.models.deletion +from django.db import migrations class Migration(migrations.Migration): diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 8ff422364d8b..939595ea9e9c 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -7,7 +7,6 @@ from django.db import models from django.contrib.auth.models import User from django.contrib.sessions.models import Session -from django.contrib.contenttypes.models import ContentType from django.utils.timezone import now as tz_now from django.utils.translation import gettext_lazy as _ diff --git a/requirements/requirements_git.txt b/requirements/requirements_git.txt index ebab0a405716..68d990388541 100644 --- a/requirements/requirements_git.txt +++ b/requirements/requirements_git.txt @@ -5,4 +5,4 @@ git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner # specifically need https://github.com/robgolding/django-radius/pull/27 git+https://github.com/ansible/django-radius.git@develop#egg=django-radius git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml -django-ansible-base @ git+https://github.com/alancoding/django-ansible-base@django_permissions#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac] +django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac] From 9abbd8d590e73169c7534b62680bcff6c07bb5ac Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 2 Apr 2024 15:07:39 -0400 Subject: [PATCH 13/20] [RBAC] Fix known issues with backward compatible access_list (#15052) * Remove duplicate access_list entries for direct team access * Revert test changes for superuser in access_list --- awx/api/generics.py | 8 +- awx/api/serializers.py | 63 ++++++++-- .../api/test_resource_access_lists.py | 6 +- .../functional/dab_rbac/test_access_list.py | 111 ++++++++++++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 awx/main/tests/functional/dab_rbac/test_access_list.py diff --git a/awx/api/generics.py b/awx/api/generics.py index a0c609db26fa..bc7ae2138d73 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -33,7 +33,7 @@ # django-ansible-base from ansible_base.rest_filters.rest_framework.field_lookup_backend import FieldLookupBackend from ansible_base.lib.utils.models import get_all_field_names -from ansible_base.rbac.models import RoleEvaluation +from ansible_base.rbac.models import RoleEvaluation, RoleDefinition from ansible_base.rbac.permission_registry import permission_registry # AWX @@ -810,7 +810,11 @@ def get_queryset(self): if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True)) - return (User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True)).distinct() + qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True) + auditor_role = RoleDefinition.objects.filter(name="System Auditor").first() + if auditor_role: + qs |= User.objects.filter(roleuserassignment__role_definition=auditor_role) + return qs.distinct() roles = set(Role.objects.filter(content_type=content_type, object_id=obj.id)) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c0577324c2a1..75844e9d84a9 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -21,7 +21,7 @@ # Django from django.conf import settings from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.models import User, Permission +from django.contrib.auth.models import User from django.contrib.auth.password_validation import validate_password as django_validate_password from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError @@ -45,7 +45,8 @@ # django-ansible-base from ansible_base.lib.utils.models import get_type_for_model -from ansible_base.rbac.models import RoleEvaluation +from ansible_base.rbac.models import RoleEvaluation, ObjectRole +from ansible_base.rbac import permission_registry # AWX from awx.main.access import get_user_capabilities @@ -2780,7 +2781,10 @@ def get_roles_from_perms(perm_list): if action in reversed_role_map: role_names.add(reversed_role_map[action]) elif codename in reversed_org_map: - role_names.add(codename) + if isinstance(obj, Organization): + role_names.add(reversed_org_map[codename]) + if 'view_organization' not in role_names: + role_names.add('read_role') return list(role_names) def format_role_perm(role): @@ -2799,10 +2803,10 @@ def format_role_perm(role): # Singleton roles should not be managed from this view, as per copy/edit rework spec role_dict['user_capabilities'] = {'unattach': False} - if role.singleton_name: - descendant_perms = list(Permission.objects.filter(content_type=content_type).values_list('codename', flat=True)) + model_name = content_type.model + if isinstance(obj, Organization): + descendant_perms = [codename for codename in get_role_codenames(role) if codename.endswith(model_name) or codename.startswith('add_')] else: - model_name = content_type.model descendant_perms = [codename for codename in get_role_codenames(role) if codename.endswith(model_name)] return {'role': role_dict, 'descendant_roles': get_roles_from_perms(descendant_perms)} @@ -2852,7 +2856,7 @@ def format_team_role_perm(naive_team_role, permissive_role_ids): new_roles_seen = set() all_team_roles = set() all_permissive_role_ids = set() - for evaluation in RoleEvaluation.objects.filter(role__users=user, **gfk_kwargs).prefetch_related('role'): + for evaluation in RoleEvaluation.objects.filter(role__in=user.has_roles.all(), **gfk_kwargs).prefetch_related('role'): new_role = evaluation.role if new_role.id in new_roles_seen: continue @@ -2867,9 +2871,48 @@ def format_team_role_perm(naive_team_role, permissive_role_ids): else: ret['summary_fields']['indirect_access'].append(format_role_perm(old_role)) - ret['summary_fields']['direct_access'].extend( - [y for x in (format_team_role_perm(r, direct_permissive_role_ids) for r in all_team_roles) for y in x] - ) + # Lazy role creation gives us a big problem, where some intermediate roles are not easy to find + # like when a team has indirect permission, so here we get all roles the users teams have + # these contribute to all potential permission-granting roles of the object + user_teams_qs = permission_registry.team_model.objects.filter(member_roles__in=ObjectRole.objects.filter(users=user)) + team_obj_roles = ObjectRole.objects.filter(teams__in=user_teams_qs) + for evaluation in RoleEvaluation.objects.filter(role__in=team_obj_roles, **gfk_kwargs).prefetch_related('role'): + new_role = evaluation.role + if new_role.id in new_roles_seen: + continue + new_roles_seen.add(new_role.id) + old_role = get_role_from_object_role(new_role) + all_permissive_role_ids.add(old_role.id) + + # In DAB RBAC, superuser is strictly a user flag, and global roles are not in the RoleEvaluation table + if user.is_superuser: + ret['summary_fields'].setdefault('indirect_access', []) + all_role_names = [field.name for field in obj._meta.get_fields() if isinstance(field, ImplicitRoleField)] + ret['summary_fields']['indirect_access'].append( + { + "role": { + "id": None, + "name": _("System Administrator"), + "description": _("Can manage all aspects of the system"), + "user_capabilities": {"unattach": False}, + }, + "descendant_roles": all_role_names, + } + ) + elif user.is_system_auditor: + ret['summary_fields'].setdefault('indirect_access', []) + ret['summary_fields']['indirect_access'].append( + { + "role": { + "id": None, + "name": _("System Auditor"), + "description": _("Can view all aspects of the system"), + "user_capabilities": {"unattach": False}, + }, + "descendant_roles": ["read_role"], + } + ) + ret['summary_fields']['direct_access'].extend([y for x in (format_team_role_perm(r, all_permissive_role_ids) for r in all_team_roles) for y in x]) return ret diff --git a/awx/main/tests/functional/api/test_resource_access_lists.py b/awx/main/tests/functional/api/test_resource_access_lists.py index 3b524f50f298..71d107dbda28 100644 --- a/awx/main/tests/functional/api/test_resource_access_lists.py +++ b/awx/main/tests/functional/api/test_resource_access_lists.py @@ -1,6 +1,7 @@ import pytest from awx.api.versioning import reverse +from awx.main.models import Role @pytest.mark.django_db @@ -38,7 +39,7 @@ def test_indirect_access_list(get, organization, project, team_factory, user, ad assert len(team_admin_res['summary_fields']['direct_access']) == 1 assert len(team_admin_res['summary_fields']['indirect_access']) == 0 assert len(admin_res['summary_fields']['direct_access']) == 0 - assert len(admin_res['summary_fields']['indirect_access']) == 0 # decreased to 0 because system admin role no longer exists + assert len(admin_res['summary_fields']['indirect_access']) == 1 project_admin_entry = project_admin_res['summary_fields']['direct_access'][0]['role'] assert project_admin_entry['id'] == project.admin_role.id @@ -51,3 +52,6 @@ def test_indirect_access_list(get, organization, project, team_factory, user, ad assert project_admin_team_member_entry['id'] == project.admin_role.id assert project_admin_team_member_entry['team_id'] == project_admin_team.id assert project_admin_team_member_entry['team_name'] == project_admin_team.name + + admin_entry = admin_res['summary_fields']['indirect_access'][0]['role'] + assert admin_entry['name'] == Role.singleton('system_administrator').name diff --git a/awx/main/tests/functional/dab_rbac/test_access_list.py b/awx/main/tests/functional/dab_rbac/test_access_list.py new file mode 100644 index 000000000000..2a88b5b18f4c --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/test_access_list.py @@ -0,0 +1,111 @@ +import pytest + +from awx.main.models import User +from awx.api.versioning import reverse + + +@pytest.mark.django_db +def test_access_list_superuser(get, admin_user, inventory): + url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id}) + + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'admin' in by_username + + assert len(by_username['admin']['summary_fields']['indirect_access']) == 1 + assert len(by_username['admin']['summary_fields']['direct_access']) == 0 + access_entry = by_username['admin']['summary_fields']['indirect_access'][0] + assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role']) + + +@pytest.mark.django_db +def test_access_list_system_auditor(get, admin_user, inventory): + sys_auditor = User.objects.create(username='sys-aud') + sys_auditor.is_system_auditor = True + assert sys_auditor.is_system_auditor + url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id}) + + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'sys-aud' in by_username + + assert len(by_username['sys-aud']['summary_fields']['indirect_access']) == 1 + assert len(by_username['sys-aud']['summary_fields']['direct_access']) == 0 + access_entry = by_username['sys-aud']['summary_fields']['indirect_access'][0] + assert access_entry['descendant_roles'] == ['read_role'] + + +@pytest.mark.django_db +def test_access_list_direct_access(get, admin_user, inventory): + u1 = User.objects.create(username='u1') + + inventory.admin_role.members.add(u1) + + url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id}) + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'u1' in by_username + + assert len(by_username['u1']['summary_fields']['direct_access']) == 1 + assert len(by_username['u1']['summary_fields']['indirect_access']) == 0 + access_entry = by_username['u1']['summary_fields']['direct_access'][0] + assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role']) + + +@pytest.mark.django_db +def test_access_list_organization_access(get, admin_user, inventory): + u2 = User.objects.create(username='u2') + + inventory.organization.inventory_admin_role.members.add(u2) + + # User has indirect access to the inventory + url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id}) + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'u2' in by_username + + assert len(by_username['u2']['summary_fields']['indirect_access']) == 1 + assert len(by_username['u2']['summary_fields']['direct_access']) == 0 + access_entry = by_username['u2']['summary_fields']['indirect_access'][0] + assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role']) + + # Test that user shows up in the organization access list with direct access of expected roles + url = reverse('api:organization_access_list', kwargs={'pk': inventory.organization_id}) + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'u2' in by_username + + assert len(by_username['u2']['summary_fields']['direct_access']) == 1 + assert len(by_username['u2']['summary_fields']['indirect_access']) == 0 + access_entry = by_username['u2']['summary_fields']['direct_access'][0] + assert sorted(access_entry['descendant_roles']) == sorted(['inventory_admin_role', 'read_role']) + + +@pytest.mark.django_db +def test_team_indirect_access(get, team, admin_user, inventory): + u1 = User.objects.create(username='u1') + team.member_role.members.add(u1) + + inventory.organization.inventory_admin_role.parents.add(team.member_role) + + url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id}) + response = get(url, user=admin_user, expect=200) + by_username = {} + for entry in response.data['results']: + by_username[entry['username']] = entry + assert 'u1' in by_username + + assert len(by_username['u1']['summary_fields']['direct_access']) == 1 + assert len(by_username['u1']['summary_fields']['indirect_access']) == 0 + access_entry = by_username['u1']['summary_fields']['direct_access'][0] + assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role']) From c917b8675ba131110bfbdbc62a8028e4aa20bfe2 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 2 Apr 2024 15:26:07 -0400 Subject: [PATCH 14/20] AWX Collections for DAB RBAC Adds new modules for CRUD operations on the following endpoints: - api/v2/role_definitions - api/v2/role_user_assignments - api/v2/role_team_assignments Note: assignment is Create or Delete only Additional changes: - Currently DAB endpoints do not have "type" field on the resource list items. So this modifies the create_or_update_if_needed to allow manually specifying item type. Signed-off-by: Seth Foster --- awx_collection/README.md | 3 +- awx_collection/meta/runtime.yml | 3 + .../plugins/module_utils/controller_api.py | 12 +- .../plugins/modules/role_definition.py | 114 ++++++++++++++++ .../plugins/modules/role_team_assignment.py | 123 +++++++++++++++++ .../plugins/modules/role_user_assignment.py | 124 ++++++++++++++++++ awx_collection/test/awx/conftest.py | 17 +++ .../test/awx/test_role_definition.py | 122 +++++++++++++++++ .../test/awx/test_role_team_assignment.py | 70 ++++++++++ .../test/awx/test_role_user_assignment.py | 70 ++++++++++ .../targets/role_definition/tasks/main.yml | 30 +++++ .../role_team_assignment/tasks/main.yml | 62 +++++++++ .../role_user_assignment/tasks/main.yml | 63 +++++++++ 13 files changed, 807 insertions(+), 6 deletions(-) create mode 100644 awx_collection/plugins/modules/role_definition.py create mode 100644 awx_collection/plugins/modules/role_team_assignment.py create mode 100644 awx_collection/plugins/modules/role_user_assignment.py create mode 100644 awx_collection/test/awx/test_role_definition.py create mode 100644 awx_collection/test/awx/test_role_team_assignment.py create mode 100644 awx_collection/test/awx/test_role_user_assignment.py create mode 100644 awx_collection/tests/integration/targets/role_definition/tasks/main.yml create mode 100644 awx_collection/tests/integration/targets/role_team_assignment/tasks/main.yml create mode 100644 awx_collection/tests/integration/targets/role_user_assignment/tasks/main.yml diff --git a/awx_collection/README.md b/awx_collection/README.md index 179ee2b9ea33..c3b6f9d52983 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -68,6 +68,7 @@ Notable releases of the `awx.awx` collection: - 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection. - 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/). - 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names + - 21.11.0 "tower" modules deprecated and symlinks removed. - X.X.X added support of named URLs to all modules. Anywhere that previously accepted name or id can also support named URLs - 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable. @@ -112,7 +113,7 @@ Ansible source, set up a dedicated virtual environment: ``` mkvirtualenv my_new_venv -# may need to replace psycopg2 with psycopg2-binary in requirements/requirements.txt +# may need to replace psycopg3 with psycopg3-binary in requirements/requirements.txt pip install -r requirements/requirements.txt -r requirements/requirements_dev.txt -r requirements/requirements_git.txt make clean-api pip install -e diff --git a/awx_collection/meta/runtime.yml b/awx_collection/meta/runtime.yml index 18fa4b592e2d..7ffdbce161b8 100644 --- a/awx_collection/meta/runtime.yml +++ b/awx_collection/meta/runtime.yml @@ -35,6 +35,9 @@ action_groups: - project - project_update - role + - role_definition + - role_team_assignment + - role_user_assignment - schedule - settings - subscriptions diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 166d43c49e41..37c784e0a2c8 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -652,7 +652,7 @@ def authenticate(self, **kwargs): # If we have neither of these, then we can try un-authenticated access self.authenticated = True - def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): + def delete_if_needed(self, existing_item, item_type=None, on_delete=None, auto_exit=True): # This will exit from the module on its own. # If the method successfully deletes an item and on_delete param is defined, # the on_delete parameter will be called as a method pasing in this object and the json from the response @@ -664,8 +664,9 @@ def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # If we have an item, we can try to delete it try: item_url = existing_item['url'] - item_type = existing_item['type'] item_id = existing_item['id'] + if not item_type: + item_type = existing_item['type'] item_name = self.get_item_name(existing_item, allow_unknown=True) except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) @@ -907,7 +908,7 @@ def objects_could_be_different(self, old, new, field_set=None, warning=False): return True return False - def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None): + def update_if_needed(self, existing_item, new_item, item_type=None, on_update=None, auto_exit=True, associations=None): # This will exit from the module on its own # If the method successfully updates an item and on_update param is defined, # the on_update parameter will be called as a method pasing in this object and the json from the response @@ -921,7 +922,8 @@ def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=Tr # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] - item_type = existing_item['type'] + if not item_type: + item_type = existing_item['type'] if item_type == 'user': item_name = existing_item['username'] elif item_type == 'workflow_job_template_node': @@ -990,7 +992,7 @@ def create_or_update_if_needed( new_item.pop(key) if existing_item: - return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) + return self.update_if_needed(existing_item, new_item, item_type=item_type, on_update=on_update, auto_exit=auto_exit, associations=associations) else: return self.create_if_needed( existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations diff --git a/awx_collection/plugins/modules/role_definition.py b/awx_collection/plugins/modules/role_definition.py new file mode 100644 index 000000000000..e226be99a508 --- /dev/null +++ b/awx_collection/plugins/modules/role_definition.py @@ -0,0 +1,114 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: role_definition +author: "Seth Foster (@fosterseth)" +short_description: Add role definition to Automation Platform Controller +description: + - Contains a list of permissions and a resource type that can then be assigned to users or teams. +options: + name: + description: + - Name of this role definition. + required: True + type: str + permissions: + description: + - List of permissions to include in the role definition. + required: True + type: list + elements: str + content_type: + description: + - The type of resource this applies to. + required: True + type: str + description: + description: + - Optional description of this role definition. + type: str + state: + description: + - The desired state of the role definition. + default: present + choices: + - present + - absent + type: str +extends_documentation_fragment: awx.awx.auth +''' + + +EXAMPLES = ''' +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to view and execute jt + state: present +''' + +from ..module_utils.controller_api import ControllerAPIModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + name=dict(required=True, type='str'), + permissions=dict(required=True, type='list', elements='str'), + content_type=dict(required=True, type='str'), + description=dict(required=False, type='str'), + state=dict(default='present', choices=['present', 'absent']), + ) + + module = ControllerAPIModule(argument_spec=argument_spec) + + name = module.params.get('name') + permissions = module.params.get('permissions') + content_type = module.params.get('content_type') + description = module.params.get('description') + state = module.params.get('state') + if description is None: + description = '' + + role_definition = module.get_one('role_definitions', name_or_id=name) + + if state == 'absent': + module.delete_if_needed( + role_definition, + item_type='role_definition', + ) + + post_kwargs = { + 'name': name, + 'permissions': permissions, + 'content_type': content_type, + 'description': description + } + + if state == 'present': + module.create_or_update_if_needed( + role_definition, + post_kwargs, + endpoint='role_definitions', + item_type='role_definition', + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/plugins/modules/role_team_assignment.py b/awx_collection/plugins/modules/role_team_assignment.py new file mode 100644 index 000000000000..a9d8c62b6626 --- /dev/null +++ b/awx_collection/plugins/modules/role_team_assignment.py @@ -0,0 +1,123 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: role_team_assignment +author: "Seth Foster (@fosterseth)" +short_description: Gives a team permission to a resource or an organization. +description: + - Use this endpoint to give a team permission to a resource or an organization. + - After creation, the assignment cannot be edited, but can be deleted to remove those permissions. +options: + role_definition: + description: + - The name or id of the role definition to assign to the team. + required: True + type: str + object_id: + description: + - Primary key of the object this assignment applies to. + required: True + type: int + team: + description: + - The name or id of the team to assign to the object. + required: False + type: str + object_ansible_id: + description: + - Resource id of the object this role applies to. Alternative to the object_id field. + required: False + type: int + team_ansible_id: + description: + - Resource id of the team who will receive permissions from this assignment. Alternative to team field. + required: False + type: int + state: + description: + - The desired state of the role definition. + default: present + choices: + - present + - absent + type: str +extends_documentation_fragment: awx.awx.auth +''' + + +EXAMPLES = ''' +- name: Give Team A JT permissions + role_team_assignment: + role_definition: launch JT + object_id: 1 + team: Team A + state: present +''' + +from ..module_utils.controller_api import ControllerAPIModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + team=dict(required=False, type='str'), + object_id=dict(required=True, type='int'), + role_definition=dict(required=True, type='str'), + object_ansible_id=dict(required=False, type='int'), + team_ansible_id=dict(required=False, type='int'), + state=dict(default='present', choices=['present', 'absent']), + ) + + module = ControllerAPIModule(argument_spec=argument_spec) + + team = module.params.get('team') + object_id = module.params.get('object_id') + role_definition_str = module.params.get('role_definition') + object_ansible_id = module.params.get('object_ansible_id') + team_ansible_id = module.params.get('team_ansible_id') + state = module.params.get('state') + + role_definition = module.get_one('role_definitions', allow_none=False, name_or_id=role_definition_str) + team = module.get_one('teams', allow_none=False, name_or_id=team) + + kwargs = { + 'role_definition': role_definition['id'], + 'object_id': object_id, + 'team': team['id'], + 'object_ansible_id': object_ansible_id, + 'team_ansible_id': team_ansible_id, + } + + # get rid of None type values + kwargs = {k: v for k, v in kwargs.items() if v is not None} + role_team_assignment = module.get_one('role_team_assignments', **{'data': kwargs}) + + if state == 'absent': + module.delete_if_needed( + role_team_assignment, + item_type='role_team_assignment', + ) + + if state == 'present': + module.create_if_needed( + role_team_assignment, + kwargs, + endpoint='role_team_assignments', + item_type='role_team_assignment', + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/plugins/modules/role_user_assignment.py b/awx_collection/plugins/modules/role_user_assignment.py new file mode 100644 index 000000000000..222db14dfcac --- /dev/null +++ b/awx_collection/plugins/modules/role_user_assignment.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: role_user_assignment +author: "Seth Foster (@fosterseth)" +short_description: Gives a user permission to a resource or an organization. +description: + - Use this endpoint to give a user permission to a resource or an organization. + - After creation, the assignment cannot be edited, but can be deleted to remove those permissions. +options: + role_definition: + description: + - The name or id of the role definition to assign to the user. + required: True + type: str + object_id: + description: + - Primary key of the object this assignment applies to. + required: True + type: int + user: + description: + - The name or id of the user to assign to the object. + required: False + type: str + object_ansible_id: + description: + - Resource id of the object this role applies to. Alternative to the object_id field. + required: False + type: int + user_ansible_id: + description: + - Resource id of the user who will receive permissions from this assignment. Alternative to user field. + required: False + type: int + state: + description: + - The desired state of the role definition. + default: present + choices: + - present + - absent + type: str +extends_documentation_fragment: awx.awx.auth +''' + + +EXAMPLES = ''' +- name: Give Bob JT permissions + role_user_assignment: + role_definition: launch JT + object_id: 1 + user: bob + state: present +''' + +from ..module_utils.controller_api import ControllerAPIModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + user=dict(required=False, type='str'), + object_id=dict(required=True, type='int'), + role_definition=dict(required=True, type='str'), + object_ansible_id=dict(required=False, type='int'), + user_ansible_id=dict(required=False, type='int'), + state=dict(default='present', choices=['present', 'absent']), + ) + + module = ControllerAPIModule(argument_spec=argument_spec) + + user = module.params.get('user') + object_id = module.params.get('object_id') + role_definition_str = module.params.get('role_definition') + object_ansible_id = module.params.get('object_ansible_id') + user_ansible_id = module.params.get('user_ansible_id') + state = module.params.get('state') + + role_definition = module.get_one('role_definitions', allow_none=False, name_or_id=role_definition_str) + user = module.get_one('users', allow_none=False, name_or_id=user) + + kwargs = { + 'role_definition': role_definition['id'], + 'object_id': object_id, + 'user': user['id'], + 'object_ansible_id': object_ansible_id, + 'user_ansible_id': user_ansible_id, + } + + # get rid of None type values + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + role_user_assignment = module.get_one('role_user_assignments', **{'data': kwargs}) + + if state == 'absent': + module.delete_if_needed( + role_user_assignment, + item_type='role_user_assignment', + ) + + if state == 'present': + module.create_if_needed( + role_user_assignment, + kwargs, + endpoint='role_user_assignments', + item_type='role_user_assignment', + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 5bf288ba690e..b7fb6333dd31 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -17,6 +17,7 @@ from ansible.module_utils.six import raise_from +from ansible_base.rbac.models import RoleDefinition, DABPermission from awx.main.tests.functional.conftest import _request from awx.main.tests.functional.conftest import credentialtype_scm, credentialtype_ssh # noqa: F401; pylint: disable=unused-variable from awx.main.models import ( @@ -31,9 +32,11 @@ WorkflowJobTemplate, NotificationTemplate, Schedule, + Team, ) from django.db import transaction +from django.contrib.contenttypes.models import ContentType HAS_TOWER_CLI = False @@ -258,6 +261,11 @@ def job_template(project, inventory): return JobTemplate.objects.create(name='test-jt', project=project, inventory=inventory, playbook='helloworld.yml') +@pytest.fixture +def team(organization): + return Team.objects.create(name='test-team', organization=organization) + + @pytest.fixture def machine_credential(credentialtype_ssh, organization): # noqa: F811 return Credential.objects.create(credential_type=credentialtype_ssh, name='machine-cred', inputs={'username': 'test_user', 'password': 'pas4word'}) @@ -331,6 +339,15 @@ def notification_template(organization): ) +@pytest.fixture +def job_template_role_definition(): + rd = RoleDefinition.objects.create(name='test_view_jt', content_type=ContentType.objects.get_for_model(JobTemplate)) + permission_codenames = ['view_jobtemplate', 'execute_jobtemplate'] + permissions = DABPermission.objects.filter(codename__in=permission_codenames) + rd.permissions.add(*permissions) + return rd + + @pytest.fixture def scm_credential(credentialtype_scm, organization): # noqa: F811 return Credential.objects.create( diff --git a/awx_collection/test/awx/test_role_definition.py b/awx_collection/test/awx/test_role_definition.py new file mode 100644 index 000000000000..b65d0259b9c0 --- /dev/null +++ b/awx_collection/test/awx/test_role_definition.py @@ -0,0 +1,122 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_base.rbac.models import RoleDefinition + + +@pytest.mark.django_db +def test_create_new(run_module, admin_user): + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + assert result['changed'] + + role_definition = RoleDefinition.objects.get(name='test_view_jt') + assert role_definition + permission_codenames = [p.codename for p in role_definition.permissions.all()] + assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate']) + assert role_definition.content_type.model == 'jobtemplate' + + +@pytest.mark.django_db +def test_update_existing(run_module, admin_user): + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + + assert result['changed'] + + role_definition = RoleDefinition.objects.get(name='test_view_jt') + permission_codenames = [p.codename for p in role_definition.permissions.all()] + assert set(permission_codenames) == set(['view_jobtemplate']) + assert role_definition.content_type.model == 'jobtemplate' + + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + + assert result['changed'] + + role_definition.refresh_from_db() + permission_codenames = [p.codename for p in role_definition.permissions.all()] + assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate']) + assert role_definition.content_type.model == 'jobtemplate' + + +@pytest.mark.django_db +def test_delete_existing(run_module, admin_user): + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + + assert result['changed'] + + role_definition = RoleDefinition.objects.get(name='test_view_jt') + assert role_definition + + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + 'state': 'absent', + }, + admin_user) + + assert result['changed'] + + with pytest.raises(RoleDefinition.DoesNotExist): + role_definition.refresh_from_db() + + +@pytest.mark.django_db +def test_idempotence(run_module, admin_user): + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + + assert result['changed'] + + result = run_module( + 'role_definition', + { + 'name': 'test_view_jt', + 'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'], + 'content_type': 'awx.jobtemplate', + }, + admin_user) + + assert not result['changed'] + + role_definition = RoleDefinition.objects.get(name='test_view_jt') + permission_codenames = [p.codename for p in role_definition.permissions.all()] + assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate']) diff --git a/awx_collection/test/awx/test_role_team_assignment.py b/awx_collection/test/awx/test_role_team_assignment.py new file mode 100644 index 000000000000..a20e7261ef54 --- /dev/null +++ b/awx_collection/test/awx/test_role_team_assignment.py @@ -0,0 +1,70 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_base.rbac.models import RoleTeamAssignment + + +@pytest.mark.django_db +def test_create_new(run_module, admin_user, team, job_template, job_template_role_definition): + result = run_module( + 'role_team_assignment', + { + 'team': team.name, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + assert RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists() + + +@pytest.mark.django_db +def test_idempotence(run_module, admin_user, team, job_template, job_template_role_definition): + result = run_module( + 'role_team_assignment', + { + 'team': team.name, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + + result = run_module( + 'role_team_assignment', + { + 'team': team.name, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert not result['changed'] + + +@pytest.mark.django_db +def test_delete_existing(run_module, admin_user, team, job_template, job_template_role_definition): + result = run_module( + 'role_team_assignment', + { + 'team': team.name, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + assert RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists() + + result = run_module( + 'role_team_assignment', + { + 'team': team.name, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + 'state': 'absent' + }, + admin_user) + assert result['changed'] + assert not RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists() diff --git a/awx_collection/test/awx/test_role_user_assignment.py b/awx_collection/test/awx/test_role_user_assignment.py new file mode 100644 index 000000000000..878aa0b5872a --- /dev/null +++ b/awx_collection/test/awx/test_role_user_assignment.py @@ -0,0 +1,70 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_base.rbac.models import RoleUserAssignment + + +@pytest.mark.django_db +def test_create_new(run_module, admin_user, job_template, job_template_role_definition): + result = run_module( + 'role_user_assignment', + { + 'user': admin_user.username, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + assert RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists() + + +@pytest.mark.django_db +def test_idempotence(run_module, admin_user, job_template, job_template_role_definition): + result = run_module( + 'role_user_assignment', + { + 'user': admin_user.username, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + + result = run_module( + 'role_user_assignment', + { + 'user': admin_user.username, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert not result['changed'] + + +@pytest.mark.django_db +def test_delete_existing(run_module, admin_user, job_template, job_template_role_definition): + result = run_module( + 'role_user_assignment', + { + 'user': admin_user.username, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + }, + admin_user) + assert result['changed'] + assert RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists() + + result = run_module( + 'role_user_assignment', + { + 'user': admin_user.username, + 'object_id': job_template.id, + 'role_definition': job_template_role_definition.name, + 'state': 'absent' + }, + admin_user) + assert result['changed'] + assert not RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists() diff --git a/awx_collection/tests/integration/targets/role_definition/tasks/main.yml b/awx_collection/tests/integration/targets/role_definition/tasks/main.yml new file mode 100644 index 000000000000..637566f20d99 --- /dev/null +++ b/awx_collection/tests/integration/targets/role_definition/tasks/main.yml @@ -0,0 +1,30 @@ +--- +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + state: present + register: result + +- assert: + that: + - result is changed + +- name: Delete Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + state: absent + register: result + +- assert: + that: + - result is changed diff --git a/awx_collection/tests/integration/targets/role_team_assignment/tasks/main.yml b/awx_collection/tests/integration/targets/role_team_assignment/tasks/main.yml new file mode 100644 index 000000000000..85ed76d8e414 --- /dev/null +++ b/awx_collection/tests/integration/targets/role_team_assignment/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Create Team + team: + name: All Stars + organization: Default + +- name: Create Job Template + job_template: + name: Demo Job Template + job_type: run + inventory: Demo Inventory + project: Demo Project + playbook: hello_world.yml + register: job_template + +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + +- name: Create Role Team Assignment + role_team_assignment: + role_definition: test_view_jt + team: All Stars + object_id: "{{ job_template.id }}" + register: result + +- assert: + that: + - result is changed + +- name: Delete Role Team Assigment + role_team_assignment: + role_definition: test_view_jt + team: All Stars + object_id: "{{ job_template.id }}" + state: absent + register: result + +- assert: + that: + - result is changed + +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + state: absent + +- name: Delete Team + team: + name: All Stars + organization: Default + state: absent diff --git a/awx_collection/tests/integration/targets/role_user_assignment/tasks/main.yml b/awx_collection/tests/integration/targets/role_user_assignment/tasks/main.yml new file mode 100644 index 000000000000..897a0ce15653 --- /dev/null +++ b/awx_collection/tests/integration/targets/role_user_assignment/tasks/main.yml @@ -0,0 +1,63 @@ +--- +- name: Create User + user: + username: testing_user + first_name: testing + last_name: user + password: password + +- name: Create Job Template + job_template: + name: Demo Job Template + job_type: run + inventory: Demo Inventory + project: Demo Project + playbook: hello_world.yml + register: job_template + +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + +- name: Create Role User Assignment + role_user_assignment: + role_definition: test_view_jt + user: testing_user + object_id: "{{ job_template.id }}" + register: result + +- assert: + that: + - result is changed + +- name: Delete Role User Assigment + role_user_assignment: + role_definition: test_view_jt + user: testing_user + object_id: "{{ job_template.id }}" + state: absent + register: result + +- assert: + that: + - result is changed + +- name: Create Role Definition + role_definition: + name: test_view_jt + permissions: + - awx.view_jobtemplate + - awx.execute_jobtemplate + content_type: awx.jobtemplate + description: role definition to launch job + state: absent + +- name: Delete User + user: + username: testing_user + state: absent From 0293ef91828fdded3bcccc3c946d8e901bb11218 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 3 Apr 2024 09:24:51 -0400 Subject: [PATCH 15/20] Fix missing role membership when giving creator permissions (#15058) --- awx/main/models/rbac.py | 26 ++++++++++++++++++- .../dab_rbac/test_translation_layer.py | 9 ++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index d99f52f4dded..ff38686efb02 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -602,13 +602,37 @@ def give_or_remove_permission(role, actor, giving=True): rd.give_or_remove_permission(actor, obj, giving=giving) +class SyncEnabled(threading.local): + def __init__(self): + self.enabled = True + + +rbac_sync_enabled = SyncEnabled() + + +@contextlib.contextmanager +def disable_rbac_sync(): + try: + previous_value = rbac_sync_enabled.enabled + rbac_sync_enabled.enabled = False + yield + finally: + rbac_sync_enabled.enabled = previous_value + + def give_creator_permissions(user, obj): - RoleDefinition.objects.give_creator_permissions(user, obj) + assignment = RoleDefinition.objects.give_creator_permissions(user, obj) + if assignment: + with disable_rbac_sync(): + old_role = get_role_from_object_role(assignment.object_role) + old_role.members.add(user) def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs): if action.startswith('pre_'): return + if not rbac_sync_enabled.enabled: + return if action == 'post_add': is_giving = True 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 8d55c6e3ce40..18a1db402833 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -1,6 +1,6 @@ import pytest -from awx.main.models.rbac import get_role_from_object_role +from awx.main.models.rbac import get_role_from_object_role, give_creator_permissions from awx.main.models import User, Organization, WorkflowJobTemplate, WorkflowJobTemplateNode from awx.api.versioning import reverse @@ -74,3 +74,10 @@ def test_workflow_approval_list(get, post, admin_user): r = get(url=reverse('api:workflow_approval_list'), user=admin_user, expect=200) assert r.data['count'] >= 1 + + +@pytest.mark.django_db +def test_creator_permission(rando, admin_user, inventory): + give_creator_permissions(rando, inventory) + assert rando in inventory.admin_role + assert rando in inventory.admin_role.members.all() From b5cd7b7f0899880b44d7779459fd76f5d1d15861 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 5 Apr 2024 10:25:29 -0400 Subject: [PATCH 16/20] [RBAC] Tweaks to reflect what endpoints are deprecated (#15068) Tweaks to reflect what endpoints are deprecated --- awx/api/generics.py | 1 + awx/api/views/__init__.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index bc7ae2138d73..f7c6803d7478 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -800,6 +800,7 @@ class RetrieveUpdateDestroyAPIView(RetrieveUpdateAPIView, DestroyAPIView): class ResourceAccessList(ParentMixin, ListAPIView): + deprecated = True serializer_class = ResourceAccessListElementSerializer ordering = ('username',) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 944f982a4d66..ce2810129a74 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -545,7 +545,6 @@ class InstanceGroupObjectRolesList(SubListAPIView): serializer_class = serializers.RoleSerializer parent_model = models.InstanceGroup search_fields = ('role_field', 'content_type__model') - deprecated = True def get_queryset(self): po = self.get_parent_object() From 770dfeff07430e1c9f6ab9ccd5fab125e65a93d5 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 5 Apr 2024 13:59:18 -0400 Subject: [PATCH 17/20] [RBAC] Fix bug where team could not be given read_role to other team (#15067) * Fix bug where team could not be given read_role to other team * Avoid unwanted triggers of parentage granting * Restructure signal structure * Fix another bug unmasked by team member permission fix * Changes to live with test writing * Use equality as opposed to string "in" from Seth in review comment Co-authored-by: Seth Foster --------- Co-authored-by: Seth Foster --- awx/api/views/__init__.py | 2 +- awx/main/models/rbac.py | 18 ++++++------- .../dab_rbac/test_translation_layer.py | 26 ++++++++++++++++++- .../functional/test_fixture_factories.py | 4 +-- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index ce2810129a74..9bc8bad28621 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -797,7 +797,7 @@ def get_queryset(self): role = ObjectRole.objects.filter(object_id=team.id, content_type=parent_ct, role_definition=rd).first() if role is None: # Team has no permissions, therefore team has no projects - return self.model.none() + return self.model.objects.none() else: project_qs = self.model.accessible_objects(self.request.user, 'read_role') return project_qs.filter(id__in=RoleEvaluation.objects.filter(content_type_id=model_ct.id, role=role).values_list('object_id')) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index ff38686efb02..a137c381fd93 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -665,8 +665,6 @@ def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs) elif action == 'post_clear': raise RuntimeError('Clearing of role members not supported') - from awx.main.models.organization import Team - if reverse: parent_role = instance else: @@ -680,15 +678,17 @@ def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs) # To a fault, we want to avoid running this if triggered from implicit_parents management # we only want to do anything if we know for sure this is a non-implicit team role - if parent_role.role_field not in ('member_role', 'admin_role') or parent_role.content_type.model != 'team': - return + if parent_role.role_field == 'member_role' and parent_role.content_type.model == 'team': + # Team internal parents are member_role->read_role and admin_role->member_role + # for the same object, this parenting will also be implicit_parents management + # do nothing for internal parents, but OTHER teams may still be assigned permissions to a team + if (child_role.content_type_id == parent_role.content_type_id) and (child_role.object_id == parent_role.object_id): + return - # Team member role is a parent of its read role so we want to avoid this - if child_role.role_field == 'read_role' and child_role.content_type.model == 'team': - return + from awx.main.models.organization import Team - team = Team.objects.get(pk=parent_role.object_id) - give_or_remove_permission(child_role, team, giving=is_giving) + team = Team.objects.get(pk=parent_role.object_id) + give_or_remove_permission(child_role, team, giving=is_giving) m2m_changed.connect(sync_members_to_new_rbac, Role.members.through) 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 18a1db402833..0d2c2c7245ed 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -1,7 +1,9 @@ +from unittest import mock + import pytest from awx.main.models.rbac import get_role_from_object_role, give_creator_permissions -from awx.main.models import User, Organization, WorkflowJobTemplate, WorkflowJobTemplateNode +from awx.main.models import User, Organization, WorkflowJobTemplate, WorkflowJobTemplateNode, Team from awx.api.versioning import reverse from ansible_base.rbac.models import RoleUserAssignment @@ -81,3 +83,25 @@ def test_creator_permission(rando, admin_user, inventory): give_creator_permissions(rando, inventory) assert rando in inventory.admin_role assert rando in inventory.admin_role.members.all() + + +@pytest.mark.django_db +def test_team_team_read_role(rando, team, admin_user, post): + orgs = [Organization.objects.create(name=f'foo-{i}') for i in range(2)] + teams = [Team.objects.create(name=f'foo-{i}', organization=orgs[i]) for i in range(2)] + teams[1].member_role.members.add(rando) + + # give second team read permission to first team through the API for regression testing + url = reverse('api:role_teams_list', kwargs={'pk': teams[0].read_role.pk, 'version': 'v2'}) + post(url, {'id': teams[1].id}, user=admin_user) + + # user should be able to view the first team + assert rando in teams[0].read_role + + +@pytest.mark.django_db +def test_implicit_parents_no_assignments(organization): + """Through the normal course of creating models, we should not be changing DAB RBAC permissions""" + with mock.patch('awx.main.models.rbac.give_or_remove_permission') as mck: + Team.objects.create(name='random team', organization=organization) + mck.assert_not_called() diff --git a/awx/main/tests/functional/test_fixture_factories.py b/awx/main/tests/functional/test_fixture_factories.py index 1af7b6624650..57921971779f 100644 --- a/awx/main/tests/functional/test_fixture_factories.py +++ b/awx/main/tests/functional/test_fixture_factories.py @@ -50,13 +50,13 @@ def test_org_factory_roles(organization_factory): teams=['team1', 'team2'], users=['team1:foo', 'bar'], projects=['baz', 'bang'], - roles=['team2.member_role:foo', 'team1.admin_role:bar', 'team1.admin_role:team2.admin_role', 'baz.admin_role:foo'], + roles=['team2.member_role:foo', 'team1.admin_role:bar', 'team1.member_role:team2.admin_role', 'baz.admin_role:foo'], ) assert objects.users.bar in objects.teams.team2.admin_role assert objects.users.foo in objects.projects.baz.admin_role assert objects.users.foo in objects.teams.team1.member_role - assert objects.teams.team2.admin_role in objects.teams.team1.admin_role.children.all() + assert objects.teams.team2.admin_role in objects.teams.team1.member_role.children.all() @pytest.mark.django_db From a69eb1f6e3c1933717ad4532ae72e1627efbb9e3 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 10 Apr 2024 11:30:24 -0400 Subject: [PATCH 18/20] [RBAC] Rename managed role definitions, and move migration logic here (#15087) * Rename managed role definitions, and move migration logic here * Fix naming capitalization --- awx/main/migrations/0192_custom_roles.py | 4 +- awx/main/migrations/_dab_rbac.py | 102 +++++++++++++++++- awx/main/models/rbac.py | 21 ++-- .../tests/functional/dab_rbac/conftest.py | 10 ++ .../functional/dab_rbac/test_dab_migration.py | 45 ++++++++ .../functional/dab_rbac/test_dab_rbac_api.py | 12 +-- .../dab_rbac/test_translation_layer.py | 12 +-- awx/settings/defaults.py | 13 ++- 8 files changed, 189 insertions(+), 30 deletions(-) create mode 100644 awx/main/tests/functional/dab_rbac/conftest.py create mode 100644 awx/main/tests/functional/dab_rbac/test_dab_migration.py diff --git a/awx/main/migrations/0192_custom_roles.py b/awx/main/migrations/0192_custom_roles.py index 3491ad67e14a..c91823aa342e 100644 --- a/awx/main/migrations/0192_custom_roles.py +++ b/awx/main/migrations/0192_custom_roles.py @@ -2,9 +2,7 @@ from django.db import migrations -from awx.main.migrations._dab_rbac import migrate_to_new_rbac, create_permissions_as_operation - -from ansible_base.rbac.migrations._managed_definitions import setup_managed_role_definitions +from awx.main.migrations._dab_rbac import migrate_to_new_rbac, create_permissions_as_operation, setup_managed_role_definitions class Migration(migrations.Migration): diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 1d8da9222fac..6e3c04882fa2 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -3,12 +3,15 @@ from django.apps import apps as global_apps from django.db.models import ForeignKey +from django.conf import settings from ansible_base.rbac.migrations._utils import give_permissions from ansible_base.rbac.management import create_dab_permissions from awx.main.fields import ImplicitRoleField from awx.main.constants import role_name_to_perm_mapping +from ansible_base.rbac.permission_registry import permission_registry + logger = logging.getLogger('awx.main.migrations._dab_rbac') @@ -194,7 +197,7 @@ def migrate_to_new_rbac(apps, schema_editor): role_definition = managed_definitions[permissions] else: action = role.role_field.rsplit('_', 1)[0] # remove the _field ending of the name - role_definition_name = f'{role.content_type.model}-{action}' + role_definition_name = f'{role.content_type.model_class().__name__} {action.title()}' description = role_descriptions[role.role_field] if type(description) == dict: @@ -241,3 +244,100 @@ def migrate_to_new_rbac(apps, schema_editor): ct += 1 if ct: logger.info(f'Migrated {ct} users to new system auditor flag') + + +def get_or_create_managed(name, description, ct, permissions, RoleDefinition): + role_definition, created = RoleDefinition.objects.get_or_create(name=name, defaults={'managed': True, 'description': description, 'content_type': ct}) + role_definition.permissions.set(list(permissions)) + + if not role_definition.managed: + role_definition.managed = True + role_definition.save(update_fields=['managed']) + + if created: + logger.info(f'Created RoleDefinition {role_definition.name} pk={role_definition} with {len(permissions)} permissions') + + return role_definition + + +def setup_managed_role_definitions(apps, schema_editor): + """ + Idepotent method to create or sync the managed role definitions + """ + to_create = settings.ANSIBLE_BASE_ROLE_PRECREATE + + ContentType = apps.get_model('contenttypes', 'ContentType') + Permission = apps.get_model('dab_rbac', 'DABPermission') + RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') + Organization = apps.get_model(settings.ANSIBLE_BASE_ORGANIZATION_MODEL) + org_ct = ContentType.objects.get_for_model(Organization) + managed_role_definitions = [] + + org_perms = set() + for cls in permission_registry._registry: + ct = ContentType.objects.get_for_model(cls) + object_perms = set(Permission.objects.filter(content_type=ct)) + # Special case for InstanceGroup which has an organiation field, but is not an organization child object + if cls._meta.model_name != 'instancegroup': + org_perms.update(object_perms) + + if 'object_admin' in to_create and cls != Organization: + indiv_perms = object_perms.copy() + add_perms = [perm for perm in indiv_perms if perm.codename.startswith('add_')] + if add_perms: + for perm in add_perms: + indiv_perms.remove(perm) + + managed_role_definitions.append( + get_or_create_managed( + to_create['object_admin'].format(cls=cls), f'Has all permissions to a single {cls._meta.verbose_name}', ct, indiv_perms, RoleDefinition + ) + ) + + if 'org_children' in to_create and cls != Organization: + org_child_perms = object_perms.copy() + org_child_perms.add(Permission.objects.get(codename='view_organization')) + + managed_role_definitions.append( + get_or_create_managed( + to_create['org_children'].format(cls=cls), + f'Has all permissions to {cls._meta.verbose_name_plural} within an organization', + org_ct, + org_child_perms, + RoleDefinition, + ) + ) + + if 'special' in to_create: + special_perms = [] + for perm in object_perms: + if perm.codename.split('_')[0] not in ('add', 'change', 'update', 'delete', 'view'): + special_perms.append(perm) + for perm in special_perms: + action = perm.codename.split('_')[0] + view_perm = Permission.objects.get(content_type=ct, codename__startswith='view_') + managed_role_definitions.append( + get_or_create_managed( + to_create['special'].format(cls=cls, action=action.title()), + f'Has {action} permissions to a single {cls._meta.verbose_name}', + ct, + [perm, view_perm], + RoleDefinition, + ) + ) + + if 'org_admin' in to_create: + managed_role_definitions.append( + get_or_create_managed( + to_create['org_admin'].format(cls=Organization), + 'Has all permissions to a single organization and all objects inside of it', + org_ct, + org_perms, + RoleDefinition, + ) + ) + + unexpected_role_definitions = RoleDefinition.objects.filter(managed=True).exclude(pk__in=[rd.pk for rd in managed_role_definitions]) + for role_definition in unexpected_role_definitions: + logger.info(f'Deleting old managed role definition {role_definition.name}, pk={role_definition.pk}') + role_definition.delete() diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index a137c381fd93..c3cdeb5f6b2a 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -553,7 +553,7 @@ def get_role_definition(role): return f = obj._meta.get_field(role.role_field) action_name = f.name.rsplit("_", 1)[0] - rd_name = f'{obj._meta.model_name}-{action_name}-compat' + rd_name = f'{type(obj).__name__} {action_name.title()} Compat' perm_list = get_role_codenames(role) defaults = {'content_type_id': role.content_type_id} try: @@ -573,23 +573,26 @@ def get_role_from_object_role(object_role): reverses naming from get_role_definition, and the ANSIBLE_BASE_ROLE_PRECREATE setting. """ rd = object_role.role_definition - if rd.name.endswith('-compat'): - model_name, role_name, _ = rd.name.split('-') + if rd.name.endswith(' Compat'): + model_name, role_name, _ = rd.name.split() + role_name = role_name.lower() role_name += '_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('-') + 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() + role_name = role_name.lower() model_cls = apps.get_model('main', target_model_name) target_model_name = get_type_for_model(model_cls) if target_model_name == 'notification_template': target_model_name = 'notification' # total exception role_name = f'{target_model_name}_admin_role' - elif rd.name.endswith('-admin'): + elif rd.name.endswith(' Admin'): # cases like "project-admin" - model_name, _ = rd.name.rsplit('-', 1) role_name = 'admin_role' else: - model_name, role_name = rd.name.split('-') + print(rd.name) + model_name, role_name = rd.name.split() + role_name = role_name.lower() role_name += '_role' return getattr(object_role.content_object, role_name) diff --git a/awx/main/tests/functional/dab_rbac/conftest.py b/awx/main/tests/functional/dab_rbac/conftest.py new file mode 100644 index 000000000000..2e37b7f7514c --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/conftest.py @@ -0,0 +1,10 @@ +import pytest +from django.apps import apps + +from awx.main.migrations._dab_rbac import setup_managed_role_definitions + + +@pytest.fixture +def managed_roles(): + "Run the migration script to pre-create managed role definitions" + setup_managed_role_definitions(apps, None) diff --git a/awx/main/tests/functional/dab_rbac/test_dab_migration.py b/awx/main/tests/functional/dab_rbac/test_dab_migration.py new file mode 100644 index 000000000000..34639774db68 --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/test_dab_migration.py @@ -0,0 +1,45 @@ +import pytest +from django.apps import apps +from django.test.utils import override_settings + +from awx.main.migrations._dab_rbac import setup_managed_role_definitions + +from ansible_base.rbac.models import RoleDefinition + +INVENTORY_OBJ_PERMISSIONS = ['view_inventory', 'adhoc_inventory', 'use_inventory', 'change_inventory', 'delete_inventory', 'update_inventory'] + + +@pytest.mark.django_db +def test_managed_definitions_precreate(): + with override_settings( + ANSIBLE_BASE_ROLE_PRECREATE={ + 'object_admin': '{cls._meta.model_name}-admin', + 'org_admin': 'organization-admin', + 'org_children': 'organization-{cls._meta.model_name}-admin', + 'special': '{cls._meta.model_name}-{action}', + } + ): + setup_managed_role_definitions(apps, None) + rd = RoleDefinition.objects.get(name='inventory-admin') + assert rd.managed is True + # add permissions do not go in the object-level admin + assert set(rd.permissions.values_list('codename', flat=True)) == set(INVENTORY_OBJ_PERMISSIONS) + + # test org-level object admin permissions + rd = RoleDefinition.objects.get(name='organization-inventory-admin') + assert rd.managed is True + assert set(rd.permissions.values_list('codename', flat=True)) == set(['add_inventory', 'view_organization'] + INVENTORY_OBJ_PERMISSIONS) + + +@pytest.mark.django_db +def test_managed_definitions_custom_obj_admin_name(): + with override_settings( + ANSIBLE_BASE_ROLE_PRECREATE={ + 'object_admin': 'foo-{cls._meta.model_name}-foo', + } + ): + setup_managed_role_definitions(apps, None) + rd = RoleDefinition.objects.get(name='foo-inventory-foo') + assert rd.managed is True + # add permissions do not go in the object-level admin + assert set(rd.permissions.values_list('codename', flat=True)) == set(INVENTORY_OBJ_PERMISSIONS) 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 7db61ae04efa..293f37c1f973 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 @@ -10,19 +10,19 @@ @pytest.mark.django_db -def test_managed_roles_created(): +def test_managed_roles_created(managed_roles): "Managed RoleDefinitions are created in post_migration signal, we expect to see them here" for cls in (JobTemplate, Inventory): ct = ContentType.objects.get_for_model(cls) rds = list(RoleDefinition.objects.filter(content_type=ct)) assert len(rds) > 1 - assert f'{cls._meta.model_name}-admin' in [rd.name for rd in rds] + assert f'{cls.__name__} Admin' in [rd.name for rd in rds] for rd in rds: assert rd.managed is True @pytest.mark.django_db -def test_custom_read_role(admin_user, post): +def test_custom_read_role(admin_user, post, managed_roles): rd_url = django_reverse('roledefinition-list') resp = post( url=rd_url, data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['view_inventory']}, user=admin_user, expect=201 @@ -40,8 +40,8 @@ def test_custom_system_roles_prohibited(admin_user, post): @pytest.mark.django_db -def test_assign_managed_role(admin_user, alice, rando, inventory, post): - rd = RoleDefinition.objects.get(name='inventory-admin') +def test_assign_managed_role(admin_user, alice, rando, inventory, post, managed_roles): + rd = RoleDefinition.objects.get(name='Inventory Admin') rd.give_permission(alice, inventory) # Now that alice has full permissions to the inventory, she will give rando permission url = django_reverse('roleuserassignment-list') @@ -63,7 +63,7 @@ def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch): @pytest.mark.django_db -def test_assign_custom_add_role(admin_user, rando, organization, post): +def test_assign_custom_add_role(admin_user, rando, organization, post, managed_roles): rd, _ = RoleDefinition.objects.get_or_create( name='inventory-add', permissions=['add_inventory', 'view_organization'], content_type=ContentType.objects.get_for_model(Organization) ) 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 0d2c2c7245ed..2829599252e4 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -14,7 +14,7 @@ 'role_name', ['execution_environment_admin_role', 'project_admin_role', 'admin_role', 'auditor_role', 'read_role', 'execute_role', 'notification_admin_role'], ) -def test_round_trip_roles(organization, rando, role_name): +def test_round_trip_roles(organization, rando, role_name, managed_roles): """ Make an assignment with the old-style role, get the equivelent new role @@ -28,7 +28,7 @@ def test_round_trip_roles(organization, rando, role_name): @pytest.mark.django_db -def test_organization_level_permissions(organization, inventory): +def test_organization_level_permissions(organization, inventory, managed_roles): u1 = User.objects.create(username='alice') u2 = User.objects.create(username='bob') @@ -58,14 +58,14 @@ def test_organization_level_permissions(organization, inventory): @pytest.mark.django_db -def test_organization_execute_role(organization, rando): +def test_organization_execute_role(organization, rando, managed_roles): organization.execute_role.members.add(rando) assert rando in organization.execute_role assert set(Organization.accessible_objects(rando, 'execute_role')) == set([organization]) @pytest.mark.django_db -def test_workflow_approval_list(get, post, admin_user): +def test_workflow_approval_list(get, post, admin_user, managed_roles): workflow_job_template = WorkflowJobTemplate.objects.create() approval_node = WorkflowJobTemplateNode.objects.create(workflow_job_template=workflow_job_template) url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': approval_node.pk, 'version': 'v2'}) @@ -79,14 +79,14 @@ def test_workflow_approval_list(get, post, admin_user): @pytest.mark.django_db -def test_creator_permission(rando, admin_user, inventory): +def test_creator_permission(rando, admin_user, inventory, managed_roles): give_creator_permissions(rando, inventory) assert rando in inventory.admin_role assert rando in inventory.admin_role.members.all() @pytest.mark.django_db -def test_team_team_read_role(rando, team, admin_user, post): +def test_team_team_read_role(rando, team, admin_user, post, managed_roles): orgs = [Organization.objects.create(name=f'foo-{i}') for i in range(2)] teams = [Team.objects.create(name=f'foo-{i}', organization=orgs[i]) for i in range(2)] teams[1].member_role.members.add(rando) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index f94b4af42807..751e41973026 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1145,14 +1145,17 @@ # Settings for the ansible_base RBAC system -# Settings for the RBAC system, override as necessary in app +# Only used internally, names of the managed RoleDefinitions to create ANSIBLE_BASE_ROLE_PRECREATE = { - 'object_admin': '{cls._meta.model_name}-admin', - 'org_admin': 'organization-admin', - 'org_children': 'organization-{cls._meta.model_name}-admin', - 'special': '{cls._meta.model_name}-{action}', + 'object_admin': '{cls.__name__} Admin', + 'org_admin': 'Organization Admin', + 'org_children': 'Organization {cls.__name__} Admin', + 'special': '{cls.__name__} {action}', } +# Name for auto-created roles that give users permissions to what they create +ANSIBLE_BASE_ROLE_CREATOR_NAME = '{cls.__name__} Creator' + # Use the new Gateway RBAC system for evaluations? You should. We will remove the old system soon. ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED = True From 8c2d9955b903f027e537992ccc14bad4e67e833c Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 10 Apr 2024 16:25:53 -0400 Subject: [PATCH 19/20] Make custom urls work with RBAC --- awx/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/urls.py b/awx/urls.py index 7df216bda12d..596d640fdb52 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -24,9 +24,9 @@ def get_urlpatterns(prefix=None): ] urlpatterns += [ - # path(f'api{prefix}v2/', include(resource_api_urls)), - path('api/v2/', include(api_version_urls)), - path('api/', include(api_urls)), + path(f'api{prefix}v2/', include(resource_api_urls)), + path(f'api{prefix}v2/', include(api_version_urls)), + path(f'api{prefix}', include(api_urls)), path('', include(root_urls)), re_path(r'^sso/', include('awx.sso.urls', namespace='sso')), re_path(r'^sso/', include('social_django.urls', namespace='social')), From 0d5338161c615b654b5b34b6381c44746bc94c89 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Thu, 11 Apr 2024 11:15:20 -0400 Subject: [PATCH 20/20] [RBAC] Update related name to reflect upstream DAB change (#15093) Update related name to reflect upstream DAB change --- awx/api/generics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index f7c6803d7478..7c7fda877ec2 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -814,7 +814,7 @@ def get_queryset(self): qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True) auditor_role = RoleDefinition.objects.filter(name="System Auditor").first() if auditor_role: - qs |= User.objects.filter(roleuserassignment__role_definition=auditor_role) + qs |= User.objects.filter(role_assignments__role_definition=auditor_role) return qs.distinct() roles = set(Role.objects.filter(content_type=content_type, object_id=obj.id))