diff options
46 files changed, 1046 insertions, 614 deletions
diff --git a/awx/api/generics.py b/awx/api/generics.py index 4020bb6757..5b68bb4195 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -30,11 +30,15 @@ from rest_framework.permissions import IsAuthenticated 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 @@ class ListAPIView(generics.ListAPIView, GenericAPIView): 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 @@ class ResourceAccessList(ParentMixin, ListAPIView): 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 @@ class CopyAPIView(GenericAPIView): ) 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 282a40697f..2423862d24 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -21,7 +21,7 @@ from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError # 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 @@ from rest_framework.utils.serializer_helpers import ReturnList # 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 @@ from awx.main.models import ( 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 @@ class ResourceAccessListElementSerializer(UserSerializer): 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 @@ class ResourceAccessListElementSerializer(UserSerializer): 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 @@ class ResourceAccessListElementSerializer(UserSerializer): 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 @@ class CredentialSerializerCreate(CredentialSerializer): 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 18b85be018..fba3260e4c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -60,6 +60,9 @@ from oauth2_provider.models import get_access_token_model 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.generics import ( 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 @@ class TeamRolesList(SubListAttachDetachAPIView): 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 @@ class TeamProjectsList(SubListAPIView): 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 @@ class TeamActivityStreamList(SubListAPIView): 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 @@ class UserTeamsList(SubListAPIView): 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 @@ class JobTemplateList(ListCreateAPIView): 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 @@ class RoleUsersList(SubListAttachDetachAPIView): class RoleTeamsList(SubListAttachDetachAPIView): + deprecated = True model = models.Team serializer_class = serializers.TeamSerializer parent_model = models.Role @@ -4320,10 +4363,12 @@ class RoleTeamsList(SubListAttachDetachAPIView): 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 @@ class RoleParentsList(SubListAPIView): 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 4085cf9bff..fb4f8e482e 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 fc8610d347..b82f4b3a4b 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 5e6dfc01b8..a9f973244e 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -132,6 +132,9 @@ class ApiVersionRootView(APIView): 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 98a25011d2..8a483b0881 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -20,7 +20,9 @@ from rest_framework.exceptions import ParseError, PermissionDenied # 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 @@ from awx.main.models import ( WorkflowJobTemplateNode, WorkflowApproval, WorkflowApprovalTemplate, - ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, - ROLE_SINGLETON_SYSTEM_AUDITOR, ) from awx.main.models.mixins import ResourceMixin @@ -264,7 +264,7 @@ class BaseAccess(object): 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 @@ class UserAccess(BaseAccess): 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 @@ class UserAccess(BaseAccess): 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 @@ class InventoryAccess(BaseAccess): 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 @@ class ExecutionEnvironmentAccess(BaseAccess): 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 @@ class JobAccess(BaseAccess): 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 @@ class ScheduleAccess(UnifiedCredentialsMixin, BaseAccess): 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 @@ class ActivityStreamAccess(BaseAccess): | 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 8800edc334..115b062604 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -114,3 +114,28 @@ SUBSCRIPTION_USAGE_MODEL_UNIQUE_HOSTS = 'unique_managed_hosts' # 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 0000000000..39202cdd2e --- /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 0000000000..a7f7c18813 --- /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 0000000000..ce6b7ba9e1 --- /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 0000000000..18c87697a9 --- /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 1a8c088523..cf16b9f258 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 @@ from django.db.models.signals import pre_delete # noqa # 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('get_queryset', get_user_queryset) 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 @@ User.add_to_class('created', created) 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 @@ activity_stream_registrar.connect(WorkflowApprovalTemplate) 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 ce96d0bd31..1d80923ee2 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -7,6 +7,9 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist 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 @@ class BaseModel(models.Model): 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 5bfbba0184..12b09095c2 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -83,6 +83,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): 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 5c1f5df810..8c8c9f919b 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -485,6 +485,9 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin, ResourceMi 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 8554f2f245..e4310f08ff 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -89,6 +89,11 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): 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 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, 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 7bd52d4438..551dd631d9 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 210d4f44a1..9d224876f4 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -19,13 +19,14 @@ from django.utils.translation import gettext_lazy as _ 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 @@ class ResourceMixin(models.Model): @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 bba0bf52c8..c34dacdde5 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -7,6 +7,7 @@ from django.conf import settings 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 Team(CommonModelNameNotUnique, ResourceMixin): 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 Profile(CreatedModifiedModel): 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 @@ if not hasattr(User, 'get_absolute_url'): 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 a22973dd62..0a571194b0 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 9078436404..3cfd813948 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -9,12 +9,22 @@ import re # 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 @@ role_descriptions = { } +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 @@ class Role(models.Model): 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 @@ class Role(models.Model): # # + if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + return + if len(additions) == 0 and len(removals) == 0: return @@ -412,6 +449,12 @@ class Role(models.Model): 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 @@ class Role(models.Model): 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 RoleAncestorEntry(models.Model): 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 0f70dc50fb..305ca29073 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -37,7 +37,8 @@ from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel, 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 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn # 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 9e8eb1a460..0451daf5bd 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 58ab17c95e..e2fb00a907 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 6192d4e9bd..4295da0a6e 100644 --- a/awx/main/tests/functional/analytics/test_metrics.py +++ b/awx/main/tests/functional/analytics/test_metrics.py @@ -4,7 +4,6 @@ from prometheus_client.parser import text_string_to_metric_families 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 0cfac1506e..f958894702 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 71d107dbda..3b524f50f2 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 cec31d9d7e..68ce8855fe 100644 --- a/awx/main/tests/functional/api/test_role.py +++ b/awx/main/tests/functional/api/test_role.py @@ -4,17 +4,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]) def test_org_object_role_assigned_to_team(post, team, organization, org_admin, role, code, reversed): diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 2c40b6ae09..8c68bd91ee 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -32,7 +32,6 @@ from awx.main.models.organization import ( 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 0000000000..00976f2d2a --- /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 0000000000..7303fe0ae7 --- /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 f8ae40b540..8be052628d 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 @@ class TestRolesAssociationEntries: 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 9807d8a6e9..271f88b21f 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 @@ -11,15 +10,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(): Organization.objects.create(name='test-organization') diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index b697ef3144..ac0179d7b8 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -3,7 +3,7 @@ import pytest 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 @@ -106,15 +104,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' organization.member_role.members.add(alice) @@ -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 1fa0f11ed5..0000000000 --- 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 bccec0a1c2..5af5c9707b 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 @@ import pytest 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 @@ class TestProjectOrganization: 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 5f1b2633e8..8ee411ba1a 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 177923b2bf..6c3e68c6c1 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 54a1cd57fe..4b9a2b78ef 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -4,7 +4,7 @@ from unittest import mock 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 @@ class TestSysAuditorTransactional(TransactionTestCase): 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 eda57579ce..0000000000 --- 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 b573d042b9..1cbaf63ca3 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -355,6 +355,7 @@ INSTALLED_APPS = [ 'ansible_base.rest_filters', 'ansible_base.jwt_consumer', 'ansible_base.resource_registry', + 'ansible_base.rbac', ] @@ -497,6 +498,12 @@ CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'un 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 @@ METRICS_SUBSYSTEM_CONFIG = { 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 @@ OPTIONAL_API_URLPATTERN_PREFIX = '' # 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 2fcfc650f9..7df216bda1 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 f5cc5ceec1..519d1f87b0 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 bd9fdf7abf..ef92ba61c2 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 350c2d48b0..ebab0a4057 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] |