diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 491e76ddb7..40ab2d3cd2 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -39,6 +39,7 @@ from polymorphic import PolymorphicModel # AWX from awx.main.constants import SCHEDULEABLE_PROVIDERS from awx.main.models import * # noqa +from awx.main.fields import ImplicitRoleField from awx.main.utils import get_type_for_model, get_model_for_type, build_url, timestamp_apiformat from awx.main.redact import REPLACE_STR from awx.main.conf import tower_settings @@ -127,7 +128,7 @@ class BaseSerializerMetaclass(serializers.SerializerMetaclass): 'foo': {'required': False, 'default': ''}, 'bar': {'label': 'New Label for Bar'}, } - + # The resulting value of extra_kwargs would be: extra_kwargs = { 'foo': {'required': False, 'default': ''}, @@ -201,7 +202,7 @@ class BaseSerializer(serializers.ModelSerializer): __metaclass__ = BaseSerializerMetaclass class Meta: - fields = ('id', 'type', 'url', 'related', 'summary_fields', 'created', + fields = ('id', 'type', 'resource_id', 'url', 'related', 'summary_fields', 'created', 'modified', 'name', 'description') summary_fields = () # FIXME: List of field names from this serializer that should be used when included as part of another's summary_fields. summarizable_fields = () # FIXME: List of field names on this serializer that should be included in summary_fields. @@ -216,6 +217,8 @@ class BaseSerializer(serializers.ModelSerializer): created = serializers.SerializerMethodField() modified = serializers.SerializerMethodField() active = serializers.SerializerMethodField() + resource_id = serializers.SerializerMethodField() + def get_type(self, obj): return get_type_for_model(self.Meta.model) @@ -254,6 +257,8 @@ class BaseSerializer(serializers.ModelSerializer): res['created_by'] = reverse('api:user_detail', args=(obj.created_by.pk,)) if getattr(obj, 'modified_by', None) and obj.modified_by.is_active: res['modified_by'] = reverse('api:user_detail', args=(obj.modified_by.pk,)) + if isinstance(obj, ResourceMixin): + res['resource'] = reverse('api:resource_detail', args=(obj.resource_id,)) return res def _get_summary_fields(self, obj): @@ -304,8 +309,30 @@ class BaseSerializer(serializers.ModelSerializer): summary_fields['modified_by'] = OrderedDict() for field in SUMMARIZABLE_FK_FIELDS['user']: summary_fields['modified_by'][field] = getattr(obj.modified_by, field) + + # RBAC summary fields + request = self.context.get('request', None) + if request and isinstance(obj, ResourceMixin) and request.user.is_authenticated(): + summary_fields['permissions'] = obj.get_permissions(request.user) + roles = {} + for field in obj._meta.get_fields(): + if type(field) is ImplicitRoleField: + role = getattr(obj, field.name) + #roles[field.name] = RoleSerializer(data=role).to_representation(role) + roles[field.name] = { + 'id': role.id, + 'name': role.name, + 'url': role.get_absolute_url(), + } + if len(roles) > 0: + summary_fields['roles'] = roles return summary_fields + def get_resource_id(self, obj): + if isinstance(obj, ResourceMixin): + return obj.resource.id + return None + def get_created(self, obj): if obj is None: return None @@ -479,6 +506,8 @@ class BaseSerializer(serializers.ModelSerializer): # set by the sub list create view. if parent_key and hasattr(view, '_raw_data_form_marker'): ret.pop(parent_key, None) + if 'resource_id' in ret and ret['resource_id'] is None: + ret.pop('resource_id') return ret @@ -737,7 +766,7 @@ class UserSerializer(BaseSerializer): admin_of_organizations = reverse('api:user_admin_of_organizations_list', args=(obj.pk,)), projects = reverse('api:user_projects_list', args=(obj.pk,)), credentials = reverse('api:user_credentials_list', args=(obj.pk,)), - permissions = reverse('api:user_permissions_list', args=(obj.pk,)), + roles = reverse('api:user_roles_list', args=(obj.pk,)), activity_stream = reverse('api:user_activity_stream_list', args=(obj.pk,)), )) return res @@ -1369,7 +1398,7 @@ class TeamSerializer(BaseSerializer): projects = reverse('api:team_projects_list', args=(obj.pk,)), users = reverse('api:team_users_list', args=(obj.pk,)), credentials = reverse('api:team_credentials_list', args=(obj.pk,)), - permissions = reverse('api:team_permissions_list', args=(obj.pk,)), + roles = reverse('api:team_roles_list', args=(obj.pk,)), activity_stream = reverse('api:team_activity_stream_list', args=(obj.pk,)), )) if obj.organization and obj.organization.active: @@ -1383,56 +1412,70 @@ class TeamSerializer(BaseSerializer): return ret -class PermissionSerializer(BaseSerializer): + +class RoleSerializer(BaseSerializer): class Meta: - model = Permission - fields = ('*', 'user', 'team', 'project', 'inventory', - 'permission_type', 'run_ad_hoc_commands') + model = Role + fields = ('*',) def get_related(self, obj): - res = super(PermissionSerializer, self).get_related(obj) - if obj.user and obj.user.is_active: - res['user'] = reverse('api:user_detail', args=(obj.user.pk,)) - if obj.team and obj.team.active: - res['team'] = reverse('api:team_detail', args=(obj.team.pk,)) - if obj.project and obj.project.active: - res['project'] = reverse('api:project_detail', args=(obj.project.pk,)) - if obj.inventory and obj.inventory.active: - res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) - return res + ret = super(RoleSerializer, self).get_related(obj) + if obj.content_object: + if type(obj.content_object) is Organization: + ret['organization'] = reverse('api:organization_detail', args=(obj.object_id,)) + if type(obj.content_object) is Team: + ret['team'] = reverse('api:team_detail', args=(obj.object_id,)) + if type(obj.content_object) is Project: + ret['project'] = reverse('api:project_detail', args=(obj.object_id,)) + if type(obj.content_object) is Inventory: + ret['inventory'] = reverse('api:inventory_detail', args=(obj.object_id,)) + if type(obj.content_object) is Host: + ret['host'] = reverse('api:host_detail', args=(obj.object_id,)) + if type(obj.content_object) is Group: + ret['group'] = reverse('api:group_detail', args=(obj.object_id,)) + if type(obj.content_object) is InventorySource: + ret['inventory_source'] = reverse('api:inventory_source_detail', args=(obj.object_id,)) + if type(obj.content_object) is Credential: + ret['credential'] = reverse('api:credential_detail', args=(obj.object_id,)) + if type(obj.content_object) is JobTemplate: + ret['job_template'] = reverse('api:job_template_detail', args=(obj.object_id,)) - def validate(self, attrs): - # Can only set either user or team. - if attrs.get('user', None) and attrs.get('team', None): - raise serializers.ValidationError('permission can only be assigned' - ' to a user OR a team, not both') - # Cannot assign admit/read/write permissions for a project. - if attrs.get('permission_type', None) in ('admin', 'read', 'write') and attrs.get('project', None): - raise serializers.ValidationError('project cannot be assigned for ' - 'inventory-only permissions') - # Project is required when setting deployment permissions. - if attrs.get('permission_type', None) in ('run', 'check') and not attrs.get('project', None): - raise serializers.ValidationError('project is required when ' - 'assigning deployment permissions') - - return super(PermissionSerializer, self).validate(attrs) - - def to_representation(self, obj): - ret = super(PermissionSerializer, self).to_representation(obj) - if obj is None: - return ret - if 'user' in ret and (not obj.user or not obj.user.is_active): - ret['user'] = None - if 'team' in ret and (not obj.team or not obj.team.active): - ret['team'] = None - if 'project' in ret and (not obj.project or not obj.project.active): - ret['project'] = None - if 'inventory' in ret and (not obj.inventory or not obj.inventory.active): - ret['inventory'] = None return ret +class ResourceSerializer(BaseSerializer): + + class Meta: + model = Resource + fields = ('*',) + + +class ResourceAccessListElementSerializer(UserSerializer): + + def to_representation(self, user): + ret = super(ResourceAccessListElementSerializer, self).to_representation(user) + resource_id = self.context['view'].resource_id + resource = Resource.objects.get(pk=resource_id) + if 'summary_fields' not in ret: + ret['summary_fields'] = {} + ret['summary_fields']['permissions'] = resource.get_permissions(user) + + def format_role_perm(role): + return { 'role': { 'id': role.id, 'name': role.name}, 'permissions': resource.get_role_permissions(role)} + + direct_permissive_role_ids = resource.permissions.values_list('role__id') + direct_access_roles = user.roles.filter(id__in=direct_permissive_role_ids).all() + ret['summary_fields']['direct_access'] = [format_role_perm(r) for r in direct_access_roles] + + all_permissive_role_ids = resource.permissions.values_list('role__ancestors__id') + indirect_access_roles = user.roles.filter(id__in=all_permissive_role_ids).exclude(id__in=direct_permissive_role_ids).all() + ret['summary_fields']['indirect_access'] = [format_role_perm(r) for r in indirect_access_roles] + return ret + + + + class CredentialSerializer(BaseSerializer): # FIXME: may want to make some fields filtered based on user accessing @@ -1705,7 +1748,7 @@ class JobRelaunchSerializer(JobSerializer): obj = self.context.get('obj') data = self.context.get('data') - # Check for passwords needed + # Check for passwords needed needed = self.get_passwords_needed_to_start(obj) provided = dict([(field, data.get(field, '')) for field in needed]) if not all(provided.values()): @@ -2292,7 +2335,7 @@ class AuthTokenSerializer(serializers.Serializer): class FactVersionSerializer(BaseFactSerializer): related = serializers.SerializerMethodField('get_related') - + class Meta: model = FactVersion fields = ('related', 'module', 'timestamp',) diff --git a/awx/api/urls.py b/awx/api/urls.py index efee8c4cdd..685c6122e7 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -30,7 +30,7 @@ user_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/admin_of_organizations/$', 'user_admin_of_organizations_list'), url(r'^(?P[0-9]+)/projects/$', 'user_projects_list'), url(r'^(?P[0-9]+)/credentials/$', 'user_credentials_list'), - url(r'^(?P[0-9]+)/permissions/$', 'user_permissions_list'), + url(r'^(?P[0-9]+)/roles/$', 'user_roles_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'user_activity_stream_list'), ) @@ -58,7 +58,7 @@ team_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/projects/$', 'team_projects_list'), url(r'^(?P[0-9]+)/users/$', 'team_users_list'), url(r'^(?P[0-9]+)/credentials/$', 'team_credentials_list'), - url(r'^(?P[0-9]+)/permissions/$', 'team_permissions_list'), + url(r'^(?P[0-9]+)/roles/$', 'team_roles_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'team_activity_stream_list'), ) @@ -141,8 +141,22 @@ credential_urls = patterns('awx.api.views', # See also credentials resources on users/teams. ) -permission_urls = patterns('awx.api.views', - url(r'^(?P[0-9]+)/$', 'permission_detail'), +role_urls = patterns('awx.api.views', + url(r'^$', 'role_list'), + url(r'^(?P[0-9]+)/$', 'role_detail'), + url(r'^(?P[0-9]+)/users/$', 'role_users_list'), + url(r'^(?P[0-9]+)/teams/$', 'role_teams_list'), + url(r'^(?P[0-9]+)/parents/$', 'role_parents_list'), + url(r'^(?P[0-9]+)/children/$', 'role_children_list'), +) + +resource_urls = patterns('awx.api.views', + #url(r'^$', 'resource_list'), + url(r'^(?P[0-9]+)/$', 'resource_detail'), + url(r'^(?P[0-9]+)/access_list/$', 'resource_access_list'), + #url(r'^(?P[0-9]+)/users/$', 'resource_users_list'), + #url(r'^(?P[0-9]+)/teams/$', 'resource_teams_list'), + #url(r'^(?P[0-9]+)/roles/$', 'resource_teams_list'), ) job_template_urls = patterns('awx.api.views', @@ -249,7 +263,8 @@ v1_urls = patterns('awx.api.views', url(r'^inventory_updates/', include(inventory_update_urls)), url(r'^inventory_scripts/', include(inventory_script_urls)), url(r'^credentials/', include(credential_urls)), - url(r'^permissions/', include(permission_urls)), + url(r'^roles/', include(role_urls)), + url(r'^resources/', include(resource_urls)), url(r'^job_templates/', include(job_template_urls)), url(r'^jobs/', include(job_urls)), url(r'^job_host_summaries/', include(job_host_summary_urls)), diff --git a/awx/api/views.py b/awx/api/views.py index c9b6bb23f4..e49741f14e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -581,7 +581,7 @@ class AuthTokenView(APIView): except IndexError: token = AuthToken.objects.create(user=serializer.validated_data['user'], request_hash=request_hash) - # Get user un-expired tokens that are not invalidated that are + # Get user un-expired tokens that are not invalidated that are # over the configured limit. # Mark them as invalid and inform the user invalid_tokens = AuthToken.get_tokens_over_limit(serializer.validated_data['user']) @@ -700,24 +700,29 @@ class TeamUsersList(SubListCreateAttachDetachAPIView): parent_model = Team relationship = 'users' -class TeamPermissionsList(SubListCreateAttachDetachAPIView): - model = Permission - serializer_class = PermissionSerializer +class TeamRolesList(SubListCreateAttachDetachAPIView): + + model = Role + serializer_class = RoleSerializer parent_model = Team - relationship = 'permissions' - parent_key = 'team' + relationship='member_role.children' def get_queryset(self): - # FIXME: Default get_queryset should handle this. + # XXX: This needs to be the intersection between + # what roles the user has and what roles the viewer + # has access to see. team = Team.objects.get(pk=self.kwargs['pk']) - base = Permission.objects.filter(team = team) - #if Team.can_user_administrate(self.request.user, team, None): - if self.request.user.can_access(Team, 'change', team, None): - return base - elif team.users.filter(pk=self.request.user.pk).count() > 0: - return base - raise PermissionDenied() + return team.member_role.children + + # XXX: Need to enforce permissions + def post(self, request, *args, **kwargs): + # Forbid implicit role creation here + sub_id = request.data.get('id', None) + if not sub_id: + data = dict(msg='Role "id" field is missing') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(type(self), self).post(request, *args, **kwargs) class TeamProjectsList(SubListCreateAttachDetachAPIView): @@ -920,13 +925,30 @@ class UserTeamsList(SubListAPIView): parent_model = User relationship = 'teams' -class UserPermissionsList(SubListCreateAttachDetachAPIView): - model = Permission - serializer_class = PermissionSerializer +class UserRolesList(SubListCreateAttachDetachAPIView): + + model = Role + serializer_class = RoleSerializer parent_model = User - relationship = 'permissions' - parent_key = 'user' + relationship='roles' + + def get_queryset(self): + # XXX: This needs to be the intersection between + # what roles the user has and what roles the viewer + # has access to see. + u = User.objects.get(pk=self.kwargs['pk']) + return u.roles + + def post(self, request, *args, **kwargs): + # Forbid implicit role creation here + sub_id = request.data.get('id', None) + if not sub_id: + data = dict(msg='Role "id" field is missing') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(type(self), self).post(request, *args, **kwargs) + + class UserProjectsList(SubListAPIView): @@ -1047,10 +1069,6 @@ class CredentialActivityStreamList(SubListAPIView): # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) -class PermissionDetail(RetrieveUpdateDestroyAPIView): - - model = Permission - serializer_class = PermissionSerializer class InventoryScriptList(ListCreateAPIView): @@ -2872,7 +2890,7 @@ class UnifiedJobStdout(RetrieveAPIView): return Response({'range': {'start': 0, 'end': 1, 'absolute_end': 1}, 'content': response_message}) else: return Response(response_message) - + if request.accepted_renderer.format in ('html', 'api', 'json'): content_format = request.query_params.get('content_format', 'html') content_encoding = request.query_params.get('content_encoding', None) @@ -3031,6 +3049,134 @@ class SettingsReset(APIView): TowerSettings.objects.filter(key=settings_key).delete() return Response(status=status.HTTP_204_NO_CONTENT) +#class RoleList(ListCreateAPIView): +class RoleList(ListAPIView): + + model = Role + serializer_class = RoleSerializer + new_in_300 = True + + # XXX: Permissions - only roles the user has access to see should be listed here + def get_queryset(self): + return Role.objects + + # XXX: Need to define who can create custom roles, and then restrict access + # appropriately + # XXX: Need to define how we want to deal with administration of custom roles. + +class RoleDetail(RetrieveUpdateAPIView): + + model = Role + serializer_class = RoleSerializer + new_in_300 = True + + # XXX: Permissions - only appropriate people should be able to change these + + +class RoleUsersList(SubListCreateAttachDetachAPIView): + + model = User + serializer_class = UserSerializer + parent_model = Role + relationship = 'members' + + def get_queryset(self): + # XXX: Access control + role = Role.objects.get(pk=self.kwargs['pk']) + return role.members + + def post(self, request, *args, **kwargs): + # Forbid implicit role creation here + sub_id = request.data.get('id', None) + if not sub_id: + data = dict(msg='Role "id" field is missing') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(type(self), self).post(request, *args, **kwargs) + + +class RoleTeamsList(ListAPIView): + + model = Team + serializer_class = TeamSerializer + parent_model = Role + relationship = 'member_role.parents' + + def get_queryset(self): + # TODO: Check + role = Role.objects.get(pk=self.kwargs['pk']) + return Team.objects.filter(member_role__children__in=[role]) + + def post(self, request, pk, *args, **kwargs): + # Forbid implicit role creation here + sub_id = request.data.get('id', None) + if not sub_id: + data = dict(msg='Role "id" field is missing') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + # XXX: Need to pull in can_attach and can_unattach kinda code from SubListCreateAttachDetachAPIView + role = Role.objects.get(pk=self.kwargs['pk']) + team = Team.objects.get(pk=sub_id) + if request.data.get('disassociate', None): + team.member_role.children.remove(role) + else: + team.member_role.children.add(role) + return Response(status=status.HTTP_204_NO_CONTENT) + + # XXX attach/detach needs to ensure we have the appropriate perms + + +class RoleParentsList(SubListAPIView): + + model = Role + serializer_class = RoleSerializer + parent_model = Role + relationship = 'parents' + + def get_queryset(self): + # XXX: This should be the intersection between the roles of the user + # and the roles that the requesting user has access to see + role = Role.objects.get(pk=self.kwargs['pk']) + return role.parents + +class RoleChildrenList(SubListAPIView): + + model = Role + serializer_class = RoleSerializer + parent_model = Role + relationship = 'children' + + def get_queryset(self): + # XXX: This should be the intersection between the roles of the user + # and the roles that the requesting user has access to see + role = Role.objects.get(pk=self.kwargs['pk']) + return role.children + +class ResourceDetail(RetrieveAPIView): + + model = Resource + serializer_class = ResourceSerializer + new_in_300 = True + + # XXX: Permissions - only roles the user has access to see should be listed here + def get_queryset(self): + return Resource.objects + +class ResourceAccessList(ListAPIView): + + model = User + serializer_class = ResourceAccessListElementSerializer + new_in_300 = True + + def get_queryset(self): + self.resource_id = self.kwargs['pk'] + resource = Resource.objects.get(pk=self.kwargs['pk']) + roles = set([p.role for p in resource.permissions.all()]) + ancestors = set() + for r in roles: + ancestors.update(set(r.ancestors.all())) + return User.objects.filter(roles__in=list(ancestors)) + + + # Create view functions for all of the class-based views to simplify inclusion # in URL patterns and reverse URL lookups, converting CamelCase names to # lowercase_with_underscore (e.g. MyView.as_view() becomes my_view). diff --git a/awx/main/access.py b/awx/main/access.py index 0cafdb918e..5a7ec03263 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1659,6 +1659,64 @@ class TowerSettingsAccess(BaseAccess): def can_delete(self, obj): return self.user.is_superuser + +class RoleAccess(BaseAccess): + ''' + TODO: XXX: Needs implemenation + ''' + + model = Role + + def get_queryset(self): + if self.user.is_superuser: + return self.model.objects.all() + return self.model.objects.none() + + def can_change(self, obj, data): + return self.user.is_superuser + + def can_add(self, obj, data): + return self.user.is_superuser + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + return self.user.is_superuser + + def can_unattach(self, obj, sub_obj, relationship): + return self.user.is_superuser + + def can_delete(self, obj): + return self.user.is_superuser + + +class ResourceAccess(BaseAccess): + ''' + TODO: XXX: Needs implemenation + ''' + + model = Role + + def get_queryset(self): + if self.user.is_superuser: + return self.model.objects.all() + return self.model.objects.none() + + def can_change(self, obj, data): + return self.user.is_superuser + + def can_add(self, obj, data): + return self.user.is_superuser + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + return self.user.is_superuser + + def can_unattach(self, obj, sub_obj, relationship): + return self.user.is_superuser + + def can_delete(self, obj): + return self.user.is_superuser + register_access(User, UserAccess) register_access(Organization, OrganizationAccess) register_access(Inventory, InventoryAccess) @@ -1685,3 +1743,5 @@ register_access(UnifiedJob, UnifiedJobAccess) register_access(ActivityStream, ActivityStreamAccess) register_access(CustomInventoryScript, CustomInventoryScriptAccess) register_access(TowerSettings, TowerSettingsAccess) +register_access(Role, RoleAccess) +register_access(Resource, ResourceAccess) diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index ce01f5f51e..fe505ff308 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -18,6 +18,7 @@ from awx.main.models.activity_stream import * # noqa from awx.main.models.ha import * # noqa from awx.main.models.configuration import * # noqa from awx.main.models.rbac import * # noqa +from awx.main.models.mixins import * # noqa # Monkeypatch Django serializer to ignore django-taggit fields (which break # the dumpdata command; see https://github.com/alex/django-taggit/issues/155). diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index d1aca6e325..bdf33e0a84 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -156,6 +156,35 @@ class Resource(CommonModelNameNotUnique): return {k[4:]:v for k,v in res[0].items()} return None + def get_role_permissions(self, role): + ''' + Returns a dict (or None) of the permissions a role has for a given + resource. + + Note: Each field in the dict is the `or` of all respective permissions + that have been granted to either the role or any descendents of that role. + ''' + + qs = Role.objects.filter(id=role.id, descendents__permissions__resource=self) + + qs = qs.annotate(max_create = Max('descendents__permissions__create')) + qs = qs.annotate(max_read = Max('descendents__permissions__read')) + qs = qs.annotate(max_write = Max('descendents__permissions__write')) + qs = qs.annotate(max_update = Max('descendents__permissions__update')) + qs = qs.annotate(max_delete = Max('descendents__permissions__delete')) + qs = qs.annotate(max_scm_update = Max('descendents__permissions__scm_update')) + qs = qs.annotate(max_execute = Max('descendents__permissions__execute')) + qs = qs.annotate(max_use = Max('descendents__permissions__use')) + + qs = qs.values('max_create', 'max_read', 'max_write', 'max_update', + 'max_delete', 'max_scm_update', 'max_execute', 'max_use') + + res = qs.all() + if len(res): + # strip away the 'max_' prefix + return {k[4:]:v for k,v in res[0].items()} + return None + class RolePermission(CreatedModifiedModel): ''' diff --git a/awx/main/signals.py b/awx/main/signals.py index 0067f6da0b..15821e3e32 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -338,7 +338,6 @@ model_serializer_mapping = { Credential: CredentialSerializer, Team: TeamSerializer, Project: ProjectSerializer, - Permission: PermissionSerializer, JobTemplate: JobTemplateSerializer, Job: JobSerializer, AdHocCommand: AdHocCommandSerializer, diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index d7779237b4..cea7ad01f5 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -2,9 +2,6 @@ import pytest from django.core.urlresolvers import resolve from django.utils.six.moves.urllib.parse import urlparse - -from awx.main.models.organization import Organization -from awx.main.models.ha import Instance from django.contrib.auth.models import User from rest_framework.test import ( @@ -25,6 +22,9 @@ from awx.main.models.organization import ( Team, ) +from awx.main.models.rbac import Role + + @pytest.fixture def user(): def u(name, is_superuser=False): @@ -89,6 +89,22 @@ def credential(): def inventory(organization): return Inventory.objects.create(name="test-inventory", organization=organization) +@pytest.fixture +def role(): + return Role.objects.create(name='role') + +@pytest.fixture +def admin(user): + return user('admin', True) + +@pytest.fixture +def alice(user): + return user('alice', False) + +@pytest.fixture +def bob(user): + return user('bob', False) + @pytest.fixture def group(inventory): def g(name): @@ -108,6 +124,7 @@ def permissions(): 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, } + @pytest.fixture def post(): def rf(url, data, user=None, middleware=None, **kwargs): diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py new file mode 100644 index 0000000000..0cb3166e7c --- /dev/null +++ b/awx/main/tests/functional/test_rbac_api.py @@ -0,0 +1,365 @@ +import mock # noqa +import pytest + +from django.core.urlresolvers import reverse +from awx.main.models.rbac import Role + +def mock_feature_enabled(feature, bypass_database=None): + return True + +#@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) + + +# +# /roles +# + +@pytest.mark.django_db +def test_get_roles_list_admin(organization, get, admin): + 'Admin can see list of all roles' + url = reverse('api:role_list') + response = get(url, admin) + assert response.status_code == 200 + roles = response.data + assert roles['count'] > 0 + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Unimplemented') +def test_get_roles_list_user(organization, get, user): + 'Users can see all roles they have access to, but not all roles' + assert False + + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_create_role(post, admin): + 'Admins can create new roles' + #u = user('admin', True) + response = post(reverse('api:role_list'), {'name': 'New Role'}, admin) + assert response.status_code == 201 + + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_delete_role(post, admin): + 'Admins can delete a custom role' + assert False + + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_user_create_role(organization, get, user): + 'User can create custom roles' + assert False + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_user_delete_role(organization, get, user): + 'User can delete their custom roles, but not any old row' + assert False + + + +# +# /user//roles +# + +@pytest.mark.django_db +def test_get_user_roles_list(get, admin): + url = reverse('api:user_roles_list', args=(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_add_role_to_user(role, post, admin): + assert admin.roles.filter(id=role.id).count() == 0 + url = reverse('api:user_roles_list', args=(admin.id,)) + + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert admin.roles.filter(id=role.id).count() == 1 + + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert admin.roles.filter(id=role.id).count() == 1 + + response = post(url, {}, admin) + assert response.status_code == 400 + assert admin.roles.filter(id=role.id).count() == 1 + +@pytest.mark.django_db +def test_remove_role_from_user(role, post, admin): + assert admin.roles.filter(id=role.id).count() == 0 + url = reverse('api:user_roles_list', args=(admin.id,)) + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert admin.roles.filter(id=role.id).count() == 1 + + response = post(url, {'disassociate': role.id, 'id': role.id}, admin) + assert response.status_code == 204 + assert admin.roles.filter(id=role.id).count() == 0 + + + + +# +# /team//roles +# + +@pytest.mark.django_db +def test_get_teams_roles_list(get, team, organization, admin): + team.member_role.children.add(organization.admin_role) + url = reverse('api:team_roles_list', args=(team.id,)) + response = get(url, admin) + assert response.status_code == 200 + roles = response.data + assert roles['count'] == 1 + assert roles['results'][0]['id'] == organization.admin_role.id + + +@pytest.mark.django_db +def test_add_role_to_teams(team, role, post, admin): + assert team.member_role.children.filter(id=role.id).count() == 0 + url = reverse('api:team_roles_list', args=(team.id,)) + + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert team.member_role.children.filter(id=role.id).count() == 1 + + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert team.member_role.children.filter(id=role.id).count() == 1 + + response = post(url, {}, admin) + assert response.status_code == 400 + assert team.member_role.children.filter(id=role.id).count() == 1 + +@pytest.mark.django_db +def test_remove_role_from_teams(team, role, post, admin): + assert team.member_role.children.filter(id=role.id).count() == 0 + url = reverse('api:team_roles_list', args=(team.id,)) + response = post(url, {'id': role.id}, admin) + assert response.status_code == 204 + assert team.member_role.children.filter(id=role.id).count() == 1 + + response = post(url, {'disassociate': role.id, 'id': role.id}, admin) + assert response.status_code == 204 + assert team.member_role.children.filter(id=role.id).count() == 0 + + + +# +# /roles// +# + +@pytest.mark.django_db +def test_get_role(get, admin, role): + url = reverse('api:role_detail', args=(role.id,)) + response = get(url, admin) + assert response.status_code == 200 + assert response.data['id'] == role.id + +@pytest.mark.django_db +def test_put_role(put, admin, role): + url = reverse('api:role_detail', args=(role.id,)) + response = put(url, {'name': 'Some new name'}, admin) + assert response.status_code == 200 + r = Role.objects.get(id=role.id) + assert r.name == 'Some new name' + +@pytest.mark.django_db +def test_put_role_access_denied(put, alice, admin, role): + url = reverse('api:role_detail', args=(role.id,)) + response = put(url, {'name': 'Some new name'}, alice) + assert response.status_code == 403 + + +# +# /roles//users/ +# + +@pytest.mark.django_db +def test_get_role_users(get, admin, role): + role.members.add(admin) + url = reverse('api:role_users_list', args=(role.id,)) + response = get(url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == admin.id + +@pytest.mark.django_db +def test_add_user_to_role(post, admin, role): + url = reverse('api:role_users_list', args=(role.id,)) + assert role.members.filter(id=admin.id).count() == 0 + post(url, {'id': admin.id}, admin) + assert role.members.filter(id=admin.id).count() == 1 + +@pytest.mark.django_db +def test_remove_user_to_role(post, admin, role): + role.members.add(admin) + url = reverse('api:role_users_list', args=(role.id,)) + assert role.members.filter(id=admin.id).count() == 1 + post(url, {'disassociate': True, 'id': admin.id}, admin) + assert role.members.filter(id=admin.id).count() == 0 + +# +# /roles//teams/ +# + +@pytest.mark.django_db +def test_get_role_teams(get, team, admin, role): + role.parents.add(team.member_role) + url = reverse('api:role_teams_list', args=(role.id,)) + response = get(url, admin) + print(response.data) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == team.id + + +@pytest.mark.django_db +def test_add_team_to_role(post, team, admin, role): + url = reverse('api:role_teams_list', args=(role.id,)) + assert role.members.filter(id=admin.id).count() == 0 + res = post(url, {'id': team.id}, admin) + print res.data + assert res.status_code == 204 + assert role.parents.filter(id=team.member_role.id).count() == 1 + +@pytest.mark.django_db +def test_remove_team_from_role(post, team, admin, role): + role.members.add(admin) + url = reverse('api:role_teams_list', args=(role.id,)) + assert role.members.filter(id=admin.id).count() == 1 + res = post(url, {'disassociate': True, 'id': team.id}, admin) + print res.data + assert res.status_code == 204 + assert role.parents.filter(id=team.member_role.id).count() == 0 + + +# +# /roles//parents/ +# + +@pytest.mark.django_db +def test_role_parents(get, team, admin, role): + role.parents.add(team.member_role) + url = reverse('api:role_parents_list', args=(role.id,)) + response = get(url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == team.member_role.id + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_role_add_parent(post, team, admin, role): + assert role.parents.count() == 0 + url = reverse('api:role_parents_list', args=(role.id,)) + post(url, {'id': team.member_role.id}, admin) + assert role.parents.count() == 1 + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_role_remove_parent(post, team, admin, role): + role.parents.add(team.member_role) + assert role.parents.count() == 1 + url = reverse('api:role_parents_list', args=(role.id,)) + post(url, {'disassociate': True, 'id': team.member_role.id}, admin) + assert role.parents.count() == 0 + +# +# /roles//children/ +# + +@pytest.mark.django_db +def test_role_children(get, team, admin, role): + role.parents.add(team.member_role) + url = reverse('api:role_children_list', args=(team.member_role.id,)) + response = get(url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == role.id + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_role_add_children(post, team, admin, role): + assert role.children.count() == 0 + url = reverse('api:role_children_list', args=(role.id,)) + post(url, {'id': team.member_role.id}, admin) + assert role.children.count() == 1 + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Waiting on custom role requirements') +def test_role_remove_children(post, team, admin, role): + role.children.add(team.member_role) + assert role.children.count() == 1 + url = reverse('api:role_children_list', args=(role.id,)) + post(url, {'disassociate': True, 'id': team.member_role.id}, admin) + assert role.children.count() == 0 + + + +# +# /resource//access_list +# + +@pytest.mark.django_db +def test_resource_access_list(get, team, admin, role): + team.users.add(admin) + url = reverse('api:resource_access_list', args=(team.resource.id,)) + res = get(url, admin) + assert res.status_code == 200 + + + +# +# Generics +# + +@pytest.mark.django_db +def test_ensure_rbac_fields_are_present(organization, get, admin): + url = reverse('api:organization_detail', args=(organization.id,)) + response = get(url, admin) + assert response.status_code == 200 + org = response.data + + assert 'summary_fields' in org + assert 'resource_id' in org + assert org['resource_id'] > 0 + assert org['related']['resource'] != '' + assert 'roles' in org['summary_fields'] + + org_role_response = get(org['summary_fields']['roles']['admin_role']['url'], admin) + assert org_role_response.status_code == 200 + role = org_role_response.data + assert role['related']['organization'] == url + + + + + +@pytest.mark.django_db +def test_ensure_permissions_is_present(organization, get, user): + #u = user('admin', True) + url = reverse('api:organization_detail', args=(organization.id,)) + response = get(url, user('admin', True)) + assert response.status_code == 200 + org = response.data + + assert 'summary_fields' in org + assert 'permissions' in org['summary_fields'] + assert org['summary_fields']['permissions']['read'] > 0 + +@pytest.mark.django_db +def test_ensure_role_summary_is_present(organization, get, user): + #u = user('admin', True) + url = reverse('api:organization_detail', args=(organization.id,)) + response = get(url, user('admin', True)) + assert response.status_code == 200 + org = response.data + + assert 'summary_fields' in org + assert 'roles' in org['summary_fields'] + assert org['summary_fields']['roles']['admin_role']['id'] > 0