From e0371f374540f3b4cafb1e186ea3313fc23320af Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 10:43:50 -0500 Subject: [PATCH 1/5] Switched back to multiple-organizations for Projects --- awx/main/migrations/_rbac.py | 11 +++++----- awx/main/models/organization.py | 4 ---- awx/main/models/projects.py | 12 ++--------- awx/main/tests/functional/conftest.py | 4 +++- .../tests/functional/test_rbac_project.py | 21 ++++++++++++++++--- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 76e4f83336..ddddb5c0a2 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -108,7 +108,7 @@ def migrate_projects(apps, schema_editor): Permission = apps.get_model('main', 'Permission') for project in Project.objects.all(): - if project.organization is None and project.created_by is not None: + if project.organizations.count() == 0 and project.created_by is not None: project.admin_role.members.add(project.created_by) migrations[project.name]['users'].add(project.created_by) @@ -116,10 +116,11 @@ def migrate_projects(apps, schema_editor): team.member_role.children.add(project.member_role) migrations[project.name]['teams'].add(team) - if project.organization is not None: - for user in project.organization.users.all(): - project.member_role.members.add(user) - migrations[project.name]['users'].add(user) + if project.organizations.count() > 0: + for org in project.organizations.all(): + for user in org.users.all(): + project.member_role.members.add(user) + migrations[project.name]['users'].add(user) for perm in Permission.objects.filter(project=project): # All perms at this level just imply a user or team can read diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 2648784236..2b974a6317 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -43,10 +43,6 @@ class Organization(CommonModel, ResourceMixin): blank=True, related_name='admin_of_organizations', ) - - # TODO: This field is deprecated. In 3.0 all projects will have exactly one - # organization parent, the foreign key field representing that has been - # moved to the Project model. projects = models.ManyToManyField( 'Project', blank=True, diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 593f3e40ca..0d3f628575 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -196,14 +196,6 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): app_label = 'main' ordering = ('id',) - organization = models.ForeignKey( - 'Organization', - blank=False, - null=True, - on_delete=models.SET_NULL, - related_name='project_list', # TODO: this should eventually be refactored - # back to 'projects' - anoek 2016-01-28 - ) scm_delete_on_next_update = models.BooleanField( default=False, editable=False, @@ -217,13 +209,13 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Project Administrator', - parent_role='organization.admin_role', + parent_role='organizations.admin_role', resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Project Auditor', - parent_role='organization.auditor_role', + parent_role='organizations.auditor_role', resource_field='resource', permissions = {'read': True} ) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index ca30f8315e..8ff971c795 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -29,7 +29,9 @@ def team(organization): @pytest.fixture def project(organization): - return Project.objects.create(name="test-project", organization=organization, description="test-project-desc") + prj = Project.objects.create(name="test-project", description="test-project-desc") + prj.organizations.add(organization) + return prj @pytest.fixture def user_project(user): diff --git a/awx/main/tests/functional/test_rbac_project.py b/awx/main/tests/functional/test_rbac_project.py index 95442036e4..f7625aaa31 100644 --- a/awx/main/tests/functional/test_rbac_project.py +++ b/awx/main/tests/functional/test_rbac_project.py @@ -3,10 +3,16 @@ import pytest from awx.main.migrations import _rbac as rbac from awx.main.models import Permission from django.apps import apps +from awx.main.migrations import _old_access as old_access + @pytest.mark.django_db def test_project_user_project(user_project, project, user): u = user('owner') + + assert old_access.check_user_access(u, user_project.__class__, 'read', user_project) + assert old_access.check_user_access(u, project.__class__, 'read', project) is False + assert user_project.accessible_by(u, {'read': True}) is False assert project.accessible_by(u, {'read': True}) is False migrations = rbac.migrate_projects(apps, None) @@ -20,11 +26,14 @@ def test_project_accessible_by_sa(user, project): u = user('systemadmin', is_superuser=True) assert project.accessible_by(u, {'read': True}) is False + rbac.migrate_organization(apps, None) su_migrations = rbac.migrate_users(apps, None) migrations = rbac.migrate_projects(apps, None) assert len(su_migrations) == 1 assert len(migrations[project.name]['users']) == 0 assert len(migrations[project.name]['teams']) == 0 + print(project.admin_role.ancestors.all()) + print(project.admin_role.ancestors.all()) assert project.accessible_by(u, {'read': True, 'write': True}) is True @pytest.mark.django_db @@ -58,6 +67,7 @@ def test_project_team(user, team, project): assert project.accessible_by(member, {'read': True}) is False rbac.migrate_team(apps, None) + rbac.migrate_organization(apps, None) migrations = rbac.migrate_projects(apps, None) assert len(migrations[project.name]['users']) == 0 @@ -66,13 +76,18 @@ def test_project_team(user, team, project): assert project.accessible_by(nonmember, {'read': True}) is False @pytest.mark.django_db -def test_project_explicit_permission(user, team, project): - u = user('user') - p = Permission(user=u, project=project, permission_type='check') +def test_project_explicit_permission(user, team, project, organization): + u = user('prjuser') + + assert old_access.check_user_access(u, project.__class__, 'read', project) is False + + organization.users.add(u) + p = Permission(user=u, project=project, permission_type='create', name='Perm name') p.save() assert project.accessible_by(u, {'read': True}) is False + rbac.migrate_organization(apps, None) migrations = rbac.migrate_projects(apps, None) assert len(migrations[project.name]['users']) == 1 From 243b78ee252736629391c50f8122da6dd0dd8282 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 11:48:17 -0500 Subject: [PATCH 2/5] Job template migration and migration tests --- awx/main/migrations/_old_access.py | 1693 +++++++++++++++++ awx/main/migrations/_rbac.py | 91 +- awx/main/models/jobs.py | 3 +- awx/main/tests/functional/conftest.py | 23 + .../functional/test_rbac_job_templates.py | 133 ++ 5 files changed, 1937 insertions(+), 6 deletions(-) create mode 100644 awx/main/migrations/_old_access.py create mode 100644 awx/main/tests/functional/test_rbac_job_templates.py diff --git a/awx/main/migrations/_old_access.py b/awx/main/migrations/_old_access.py new file mode 100644 index 0000000000..a8f54de95f --- /dev/null +++ b/awx/main/migrations/_old_access.py @@ -0,0 +1,1693 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# This file is a copy of the access.py file that existed in the 2.4 release of +# tower. We're keeping it around for a little while in order to run +# before/after access validation during the 3.0 upgrade process. Once we're +# confident that this process is reliable, this file is no longer necessary +# and can be removed. - anoek 2/9/16 + +# Python +import os +import sys +import logging + +# Django +from django.db.models import F, Q +from django.contrib.auth.models import User + +# Django REST Framework +from rest_framework.exceptions import ParseError, PermissionDenied + +# AWX +from awx.main.utils import * # noqa +from awx.main.models import * # noqa +from awx.api.license import LicenseForbids +from awx.main.task_engine import TaskSerializer +from awx.main.conf import tower_settings + +__all__ = ['get_user_queryset', 'check_user_access'] + +PERMISSION_TYPES = [ + PERM_INVENTORY_ADMIN, + PERM_INVENTORY_READ, + PERM_INVENTORY_WRITE, + PERM_INVENTORY_DEPLOY, + PERM_INVENTORY_CHECK, +] + +PERMISSION_TYPES_ALLOWING_INVENTORY_READ = [ + PERM_INVENTORY_ADMIN, + PERM_INVENTORY_WRITE, + PERM_INVENTORY_READ, +] + +PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE = [ + PERM_INVENTORY_ADMIN, + PERM_INVENTORY_WRITE, +] + +PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN = [ + PERM_INVENTORY_ADMIN, +] + +logger = logging.getLogger('awx.main.access') + +access_registry = { + # : [, ...], + # ... +} + +def register_access(model_class, access_class): + access_classes = access_registry.setdefault(model_class, []) + access_classes.append(access_class) + +def get_user_queryset(user, model_class): + ''' + Return a queryset for the given model_class containing only the instances + that should be visible to the given user. + ''' + querysets = [] + for access_class in access_registry.get(model_class, []): + access_instance = access_class(user) + querysets.append(access_instance.get_queryset()) + if not querysets: + return model_class.objects.none() + elif len(querysets) == 1: + return querysets[0] + else: + queryset = model_class.objects.all() + for qs in querysets: + queryset = queryset.filter(pk__in=qs.values_list('pk', flat=True)) + return queryset + +def check_user_access(user, model_class, action, *args, **kwargs): + ''' + Return True if user can perform action against model_class with the + provided parameters. + ''' + for access_class in access_registry.get(model_class, []): + access_instance = access_class(user) + access_method = getattr(access_instance, 'can_%s' % action, None) + if not access_method: + logger.debug('%s.%s not found', access_instance.__class__.__name__, + 'can_%s' % action) + continue + result = access_method(*args, **kwargs) + logger.debug('%s.%s %r returned %r', access_instance.__class__.__name__, + access_method.__name__, args, result) + if result: + return result + return False + + +class BaseAccess(object): + ''' + Base class for checking user access to a given model. Subclasses should + define the model attribute, override the get_queryset method to return only + the instances the user should be able to view, and override/define can_* + methods to verify a user's permission to perform a particular action. + ''' + + model = None + + def __init__(self, user): + self.user = user + + def get_queryset(self): + if self.user.is_superuser: + return self.model.objects.all() + else: + return self.model.objects.none() + + def can_read(self, obj): + return bool(obj and self.get_queryset().filter(pk=obj.pk).exists()) + + def can_add(self, data): + return self.user.is_superuser + + def can_change(self, obj, data): + return self.user.is_superuser + + def can_write(self, obj, data): + # Alias for change. + return self.can_change(obj, data) + + def can_admin(self, obj, data): + # Alias for can_change. Can be overridden if admin vs. user change + # permissions need to be different. + return self.can_change(obj, data) + + def can_delete(self, obj): + return self.user.is_superuser + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + if skip_sub_obj_read_check: + return self.can_change(obj, None) + else: + return bool(self.can_change(obj, None) and + check_user_access(self.user, type(sub_obj), 'read', sub_obj)) + + def can_unattach(self, obj, sub_obj, relationship): + return self.can_change(obj, None) + + def check_license(self, add_host=False, feature=None, check_expiration=True): + reader = TaskSerializer() + validation_info = reader.from_database() + if ('test' in sys.argv or 'py.test' in sys.argv[0] or 'jenkins' in sys.argv) and not os.environ.get('SKIP_LICENSE_FIXUP_FOR_TEST', ''): + validation_info['free_instances'] = 99999999 + validation_info['time_remaining'] = 99999999 + validation_info['grace_period_remaining'] = 99999999 + + if check_expiration and validation_info.get('time_remaining', None) is None: + raise PermissionDenied("license is missing") + if check_expiration and validation_info.get("grace_period_remaining") <= 0: + raise PermissionDenied("license has expired") + + free_instances = validation_info.get('free_instances', 0) + available_instances = validation_info.get('available_instances', 0) + if add_host and free_instances == 0: + raise PermissionDenied("license count of %s instances has been reached" % available_instances) + elif add_host and free_instances < 0: + raise PermissionDenied("license count of %s instances has been exceeded" % available_instances) + elif not add_host and free_instances < 0: + raise PermissionDenied("host count exceeds available instances") + + if feature is not None: + if "features" in validation_info and not validation_info["features"].get(feature, False): + raise LicenseForbids("Feature %s is not enabled in the active license" % feature) + elif "features" not in validation_info: + raise LicenseForbids("Features not found in active license") + + +class UserAccess(BaseAccess): + ''' + I can see user records when: + - I'm a superuser. + - I'm that user. + - I'm an org admin (org admins should be able to see all users, in order + to add those users to the org). + - I'm in an org with that user. + - I'm on a team with that user. + I can change some fields for a user (mainly password) when I am that user. + I can change all fields for a user (admin access) or delete when: + - I'm a superuser. + - I'm their org admin. + ''' + + model = User + + def get_queryset(self): + qs = self.model.objects.filter(is_active=True).distinct() + if self.user.is_superuser: + return qs + if tower_settings.ORG_ADMINS_CAN_SEE_ALL_USERS and self.user.admin_of_organizations.filter(active=True).exists(): + return qs + return qs.filter( + Q(pk=self.user.pk) | + Q(organizations__in=self.user.admin_of_organizations.filter(active=True)) | + Q(organizations__in=self.user.organizations.filter(active=True)) | + Q(teams__in=self.user.teams.filter(active=True)) + ).distinct() + + def can_add(self, data): + if data is not None and 'is_superuser' in data: + if to_python_boolean(data['is_superuser'], allow_none=True) and not self.user.is_superuser: + return False + return bool(self.user.is_superuser or + self.user.admin_of_organizations.filter(active=True).exists()) + + def can_change(self, obj, data): + if data is not None and 'is_superuser' in data: + if to_python_boolean(data['is_superuser'], allow_none=True) and not self.user.is_superuser: + return False + # A user can be changed if they are themselves, or by org admins or + # superusers. Change permission implies changing only certain fields + # that a user should be able to edit for themselves. + return bool(self.user == obj or self.can_admin(obj, data)) + + def can_admin(self, obj, data): + # Admin implies changing all user fields. + if self.user.is_superuser: + return True + return bool(obj.organizations.filter(active=True, admins__in=[self.user]).exists()) + + def can_delete(self, obj): + if obj == self.user: + # cannot delete yourself + return False + super_users = User.objects.filter(is_active=True, is_superuser=True) + if obj.is_superuser and super_users.count() == 1: + # cannot delete the last active superuser + return False + return bool(self.user.is_superuser or + obj.organizations.filter(active=True, admins__in=[self.user]).exists()) + +class OrganizationAccess(BaseAccess): + ''' + I can see organizations when: + - I am a superuser. + - I am an admin or user in that organization. + I can change or delete organizations when: + - I am a superuser. + - I'm an admin of that organization. + ''' + + model = Organization + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by') + if self.user.is_superuser: + return qs + return qs.filter(Q(admins__in=[self.user]) | Q(users__in=[self.user])) + + def can_change(self, obj, data): + return bool(self.user.is_superuser or + self.user in obj.admins.all()) + + def can_delete(self, obj): + self.check_license(feature='multiple_organizations', check_expiration=False) + return self.can_change(obj, None) + +class InventoryAccess(BaseAccess): + ''' + I can see inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have read, write or admin permissions on it. + I can change inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have write or admin permissions on it. + I can delete inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have admin permissions on it. + I can run ad hoc commands when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have read/write/admin permission on an inventory with the run_ad_hoc_commands flag set. + ''' + + model = Inventory + + def get_queryset(self, allowed=None, ad_hoc=None): + allowed = allowed or PERMISSION_TYPES_ALLOWING_INVENTORY_READ + qs = Inventory.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'organization') + if self.user.is_superuser: + return qs + qs = qs.filter(organization__active=True) + admin_of = qs.filter(organization__admins__in=[self.user]).distinct() + has_user_kw = dict( + permissions__user__in=[self.user], + permissions__permission_type__in=allowed, + permissions__active=True, + ) + if ad_hoc is not None: + has_user_kw['permissions__run_ad_hoc_commands'] = ad_hoc + has_user_perms = qs.filter(**has_user_kw).distinct() + has_team_kw = dict( + permissions__team__users__in=[self.user], + permissions__team__active=True, + permissions__permission_type__in=allowed, + permissions__active=True, + ) + if ad_hoc is not None: + has_team_kw['permissions__run_ad_hoc_commands'] = ad_hoc + has_team_perms = qs.filter(**has_team_kw).distinct() + return admin_of | has_user_perms | has_team_perms + + def has_permission_types(self, obj, allowed, ad_hoc=None): + return bool(obj and self.get_queryset(allowed, ad_hoc).filter(pk=obj.pk).exists()) + + def can_read(self, obj): + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ) + + def can_add(self, data): + # If no data is specified, just checking for generic add permission? + if not data: + return bool(self.user.is_superuser or + self.user.admin_of_organizations.filter(active=True).exists()) + # Otherwise, verify that the user has access to change the parent + # organization of this inventory. + if self.user.is_superuser: + return True + else: + org_pk = get_pk_from_dict(data, 'organization') + org = get_object_or_400(Organization, pk=org_pk) + if check_user_access(self.user, Organization, 'change', org, None): + return True + return False + + def can_change(self, obj, data): + # Verify that the user has access to the new organization if moving an + # inventory to a new organization. + org_pk = get_pk_from_dict(data, 'organization') + if obj and org_pk and obj.organization.pk != org_pk: + org = get_object_or_400(Organization, pk=org_pk) + if not check_user_access(self.user, Organization, 'change', org, None): + return False + # Otherwise, just check for write permission. + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) + + def can_admin(self, obj, data): + # Verify that the user has access to the new organization if moving an + # inventory to a new organization. + org_pk = get_pk_from_dict(data, 'organization') + if obj and org_pk and obj.organization.pk != org_pk: + org = get_object_or_400(Organization, pk=org_pk) + if not check_user_access(self.user, Organization, 'change', org, None): + return False + # Otherwise, just check for admin permission. + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN) + + def can_delete(self, obj): + return self.can_admin(obj, None) + + def can_run_ad_hoc_commands(self, obj): + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ, True) + +class HostAccess(BaseAccess): + ''' + I can see hosts whenever I can see their inventory. + I can change or delete hosts whenver I can change their inventory. + ''' + + model = Host + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory', + 'last_job__job_template', + 'last_job_host_summary__job') + qs = qs.prefetch_related('groups') + inventory_ids = set(self.user.get_queryset(Inventory).values_list('id', flat=True)) + return qs.filter(inventory_id__in=inventory_ids) + + def can_read(self, obj): + return obj and check_user_access(self.user, Inventory, 'read', obj.inventory) + + def can_add(self, data): + if not data or 'inventory' not in data: + return False + + # Checks for admin or change permission on inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + inventory = get_object_or_400(Inventory, pk=inventory_pk) + if not check_user_access(self.user, Inventory, 'change', inventory, None): + return False + + # Check to see if we have enough licenses + self.check_license(add_host=True) + return True + + def can_change(self, obj, data): + # Prevent moving a host to a different inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + if obj and inventory_pk and obj.inventory.pk != inventory_pk: + raise PermissionDenied('Unable to change inventory on a host') + # Checks for admin or change permission on inventory, controls whether + # the user can edit variable data. + return obj and check_user_access(self.user, Inventory, 'change', obj.inventory, None) + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + if not super(HostAccess, self).can_attach(obj, sub_obj, relationship, + data, skip_sub_obj_read_check): + return False + # Prevent assignments between different inventories. + if obj.inventory != sub_obj.inventory: + raise ParseError('Cannot associate two items from different inventories') + return True + + def can_delete(self, obj): + return obj and check_user_access(self.user, Inventory, 'delete', obj.inventory) + +class GroupAccess(BaseAccess): + ''' + I can see groups whenever I can see their inventory. + I can change or delete groups whenever I can change their inventory. + ''' + + model = Group + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory') + qs = qs.prefetch_related('parents', 'children', 'inventory_source') + inventory_ids = set(self.user.get_queryset(Inventory).values_list('id', flat=True)) + return qs.filter(inventory_id__in=inventory_ids) + + def can_read(self, obj): + return obj and check_user_access(self.user, Inventory, 'read', obj.inventory) + + def can_add(self, data): + if not data or 'inventory' not in data: + return False + # Checks for admin or change permission on inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + inventory = get_object_or_400(Inventory, pk=inventory_pk) + return check_user_access(self.user, Inventory, 'change', inventory, None) + + def can_change(self, obj, data): + # Prevent moving a group to a different inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + if obj and inventory_pk and obj.inventory.pk != inventory_pk: + raise PermissionDenied('Unable to change inventory on a group') + # Checks for admin or change permission on inventory, controls whether + # the user can attach subgroups or edit variable data. + return obj and check_user_access(self.user, Inventory, 'change', obj.inventory, None) + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + if not super(GroupAccess, self).can_attach(obj, sub_obj, relationship, + data, skip_sub_obj_read_check): + return False + # Don't allow attaching if the sub obj is not active + if not obj.active: + return False + # Prevent assignments between different inventories. + if obj.inventory != sub_obj.inventory: + raise ParseError('Cannot associate two items from different inventories') + # Prevent group from being assigned as its own (grand)child. + if type(obj) == type(sub_obj): + parent_pks = set(obj.all_parents.values_list('pk', flat=True)) + parent_pks.add(obj.pk) + child_pks = set(sub_obj.all_children.values_list('pk', flat=True)) + child_pks.add(sub_obj.pk) + if parent_pks & child_pks: + return False + return True + + def can_delete(self, obj): + return obj and check_user_access(self.user, Inventory, 'delete', obj.inventory) + + +class InventorySourceAccess(BaseAccess): + ''' + I can see inventory sources whenever I can see their group or inventory. + I can change inventory sources whenever I can change their group. + ''' + + model = InventorySource + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'group', 'inventory') + inventory_ids = set(self.user.get_queryset(Inventory).values_list('id', flat=True)) + return qs.filter(Q(inventory_id__in=inventory_ids) | + Q(group__inventory_id__in=inventory_ids)) + + def can_read(self, obj): + if obj and obj.group: + return check_user_access(self.user, Group, 'read', obj.group) + elif obj and obj.inventory: + return check_user_access(self.user, Inventory, 'read', obj.inventory) + else: + return False + + def can_add(self, data): + # Automatically created from group or management command. + return False + + def can_change(self, obj, data): + # Checks for admin or change permission on group. + if obj and obj.group: + return check_user_access(self.user, Group, 'change', obj.group, None) + # Can't change inventory sources attached to only the inventory, since + # these are created automatically from the management command. + else: + return False + + def can_start(self, obj): + return self.can_change(obj, {}) and obj.can_update + +class InventoryUpdateAccess(BaseAccess): + ''' + I can see inventory updates when I can see the inventory source. + I can change inventory updates whenever I can change their source. + I can delete when I can change/delete the inventory source. + ''' + + model = InventoryUpdate + + def get_queryset(self): + qs = InventoryUpdate.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory_source__group', + 'inventory_source__inventory') + inventory_sources_qs = self.user.get_queryset(InventorySource) + return qs.filter(inventory_source__in=inventory_sources_qs) + + def can_cancel(self, obj): + return self.can_change(obj, {}) and obj.can_cancel + +class CredentialAccess(BaseAccess): + ''' + I can see credentials when: + - I'm a superuser. + - It's a user credential and it's my credential. + - It's a user credential and I'm an admin of an organization where that + user is a member of admin of the organization. + - It's a team credential and I'm an admin of the team's organization. + - It's a team credential and I'm a member of the team. + I can change/delete when: + - I'm a superuser. + - It's my user credential. + - It's a user credential for a user in an org I admin. + - It's a team credential for a team in an org I admin. + ''' + + model = Credential + + def get_queryset(self): + """Return the queryset for credentials, based on what the user is + permitted to see. + """ + # Create a base queryset. + # If the user is a superuser, and therefore can see everything, this + # is also sufficient, and we are done. + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'user', 'team') + if self.user.is_superuser: + return qs + + # Get the list of organizations for which the user is an admin + orgs_as_admin_ids = set(self.user.admin_of_organizations.filter(active=True).values_list('id', flat=True)) + return qs.filter( + Q(user=self.user) | + Q(user__organizations__id__in=orgs_as_admin_ids) | + Q(user__admin_of_organizations__id__in=orgs_as_admin_ids) | + Q(team__organization__id__in=orgs_as_admin_ids, team__active=True) | + Q(team__users__in=[self.user], team__active=True) + ) + + def can_add(self, data): + if self.user.is_superuser: + return True + user_pk = get_pk_from_dict(data, 'user') + if user_pk: + user_obj = get_object_or_400(User, pk=user_pk) + return check_user_access(self.user, User, 'change', user_obj, None) + team_pk = get_pk_from_dict(data, 'team') + if team_pk: + team_obj = get_object_or_400(Team, pk=team_pk) + return check_user_access(self.user, Team, 'change', team_obj, None) + return False + + def can_change(self, obj, data): + if self.user.is_superuser: + return True + if not self.can_add(data): + return False + if self.user == obj.created_by: + return True + if obj.user: + if self.user == obj.user: + return True + if obj.user.organizations.filter(active=True, admins__in=[self.user]).exists(): + return True + if obj.user.admin_of_organizations.filter(active=True, admins__in=[self.user]).exists(): + return True + if obj.team: + if self.user in obj.team.organization.admins.filter(is_active=True): + return True + return False + + def can_delete(self, obj): + # Unassociated credentials may be marked deleted by anyone, though we + # shouldn't ever end up with those. + if obj.user is None and obj.team is None: + return True + return self.can_change(obj, None) + +class TeamAccess(BaseAccess): + ''' + I can see a team when: + - I'm a superuser. + - I'm an admin of the team's organization. + - I'm a member of that team. + I can create/change a team when: + - I'm a superuser. + - I'm an org admin for the team's org. + ''' + + model = Team + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'organization') + if self.user.is_superuser: + return qs + return qs.filter( + Q(organization__admins__in=[self.user], organization__active=True) | + Q(users__in=[self.user]) + ) + + def can_add(self, data): + if self.user.is_superuser: + return True + else: + org_pk = get_pk_from_dict(data, 'organization') + org = get_object_or_400(Organization, pk=org_pk) + if check_user_access(self.user, Organization, 'change', org, None): + return True + return False + + def can_change(self, obj, data): + # Prevent moving a team to a different organization. + org_pk = get_pk_from_dict(data, 'organization') + if obj and org_pk and obj.organization.pk != org_pk: + raise PermissionDenied('Unable to change organization on a team') + if self.user.is_superuser: + return True + if self.user in obj.organization.admins.all(): + return True + return False + + def can_delete(self, obj): + return self.can_change(obj, None) + +class ProjectAccess(BaseAccess): + ''' + I can see projects when: + - I am a superuser. + - I am an admin in an organization associated with the project. + - I am a user in an organization associated with the project. + - I am on a team associated with the project. + - I have been explicitly granted permission to run/check jobs using the + project. + - I created the project but it isn't associated with an organization + I can change/delete when: + - I am a superuser. + - I am an admin in an organization associated with the project. + - I created the project but it isn't associated with an organization + ''' + + model = Project + + def get_queryset(self): + qs = Project.objects.filter(active=True).distinct() + qs = qs.select_related('modified_by', 'credential', 'current_job', 'last_job') + if self.user.is_superuser: + return qs + team_ids = set(Team.objects.filter(users__in=[self.user]).values_list('id', flat=True)) + qs = qs.filter(Q(created_by=self.user, organizations__isnull=True) | + Q(organizations__admins__in=[self.user], organizations__active=True) | + Q(organizations__users__in=[self.user], organizations__active=True) | + Q(teams__in=team_ids)) + allowed_deploy = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY] + allowed_check = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY, PERM_INVENTORY_CHECK] + + deploy_permissions_ids = set(Permission.objects.filter( + Q(user=self.user) | Q(team_id__in=team_ids), + active=True, + permission_type__in=allowed_deploy, + ).values_list('id', flat=True)) + check_permissions_ids = set(Permission.objects.filter( + Q(user=self.user) | Q(team_id__in=team_ids), + active=True, + permission_type__in=allowed_check, + ).values_list('id', flat=True)) + + perm_deploy_qs = qs.filter(permissions__in=deploy_permissions_ids) + perm_check_qs = qs.filter(permissions__in=check_permissions_ids) + return qs | perm_deploy_qs | perm_check_qs + + def can_add(self, data): + if self.user.is_superuser: + return True + if self.user.admin_of_organizations.filter(active=True).exists(): + return True + return False + + def can_change(self, obj, data): + if self.user.is_superuser: + return True + if obj.created_by == self.user and not obj.organizations.filter(active=True).count(): + return True + if obj.organizations.filter(active=True, admins__in=[self.user]).exists(): + return True + return False + + def can_delete(self, obj): + return self.can_change(obj, None) + + def can_start(self, obj): + return self.can_change(obj, {}) and obj.can_update + +class ProjectUpdateAccess(BaseAccess): + ''' + I can see project updates when I can see the project. + I can change when I can change the project. + I can delete when I can change/delete the project. + ''' + + model = ProjectUpdate + + def get_queryset(self): + qs = ProjectUpdate.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'project') + project_ids = set(self.user.get_queryset(Project).values_list('id', flat=True)) + return qs.filter(project_id__in=project_ids) + + def can_cancel(self, obj): + return self.can_change(obj, {}) and obj.can_cancel + + def can_delete(self, obj): + return obj and check_user_access(self.user, Project, 'delete', obj.project) + +class PermissionAccess(BaseAccess): + ''' + I can see a permission when: + - I'm a superuser. + - I'm an org admin and it's for a user in my org. + - I'm an org admin and it's for a team in my org. + - I'm a user and it's assigned to me. + - I'm a member of a team and it's assigned to the team. + I can create/change/delete when: + - I'm a superuser. + - I'm an org admin and the team/user is in my org and the inventory is in + my org and the project is in my org. + ''' + + model = Permission + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'user', 'team', 'inventory', + 'project') + if self.user.is_superuser: + return qs + orgs_as_admin_ids = set(self.user.admin_of_organizations.filter(active=True).values_list('id', flat=True)) + return qs.filter( + Q(user__organizations__in=orgs_as_admin_ids) | + Q(user__admin_of_organizations__in=orgs_as_admin_ids) | + Q(team__organization__in=orgs_as_admin_ids, team__active=True) | + Q(user=self.user) | + Q(team__users__in=[self.user], team__active=True) + ) + + def can_add(self, data): + if not data: + return True # generic add permission check + user_pk = get_pk_from_dict(data, 'user') + team_pk = get_pk_from_dict(data, 'team') + if user_pk: + user = get_object_or_400(User, pk=user_pk) + if not check_user_access(self.user, User, 'admin', user, None): + return False + elif team_pk: + team = get_object_or_400(Team, pk=team_pk) + if not check_user_access(self.user, Team, 'admin', team, None): + return False + else: + return False + inventory_pk = get_pk_from_dict(data, 'inventory') + if inventory_pk: + inventory = get_object_or_400(Inventory, pk=inventory_pk) + if not check_user_access(self.user, Inventory, 'admin', inventory, None): + return False + project_pk = get_pk_from_dict(data, 'project') + if project_pk: + project = get_object_or_400(Project, pk=project_pk) + if not check_user_access(self.user, Project, 'admin', project, None): + return False + # FIXME: user/team, inventory and project should probably all be part + # of the same organization. + return True + + def can_change(self, obj, data): + # Prevent assigning a permission to a different user. + user_pk = get_pk_from_dict(data, 'user') + if obj and user_pk and obj.user and obj.user.pk != user_pk: + raise PermissionDenied('Unable to change user on a permission') + # Prevent assigning a permission to a different team. + team_pk = get_pk_from_dict(data, 'team') + if obj and team_pk and obj.team and obj.team.pk != team_pk: + raise PermissionDenied('Unable to change team on a permission') + if self.user.is_superuser: + return True + # If changing inventory, verify access to the new inventory. + new_inventory_pk = get_pk_from_dict(data, 'inventory') + if obj and new_inventory_pk and obj.inventory and obj.inventory.pk != new_inventory_pk: + inventory = get_object_or_400(Inventory, pk=new_inventory_pk) + if not check_user_access(self.user, Inventory, 'admin', inventory, None): + return False + # If changing project, verify access to the new project. + new_project = get_pk_from_dict(data, 'project') + if obj and new_project and obj.project and obj.project.pk != new_project: + project = get_object_or_400(Project, pk=new_project) + if not check_user_access(self.user, Project, 'admin', project, None): + return False + # Check for admin access to the user or team. + if obj.user and check_user_access(self.user, User, 'admin', obj.user, None): + return True + if obj.team and check_user_access(self.user, Team, 'admin', obj.team, None): + return True + return False + + def can_delete(self, obj): + return self.can_change(obj, None) + +class JobTemplateAccess(BaseAccess): + ''' + I can see job templates when: + - I am a superuser. + - I can read the inventory, project and credential (which means I am an + org admin or member of a team with access to all of the above). + - I have permission explicitly granted to check/deploy with the inventory + and project. + + This does not mean I would be able to launch a job from the template or + edit the template. + ''' + + model = JobTemplate + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory', 'project', + 'credential', 'cloud_credential', 'next_schedule') + if self.user.is_superuser: + return qs + credential_ids = self.user.get_queryset(Credential) + inventory_ids = self.user.get_queryset(Inventory) + base_qs = qs.filter( + Q(credential_id__in=credential_ids) | Q(credential__isnull=True), + Q(cloud_credential_id__in=credential_ids) | Q(cloud_credential__isnull=True), + ) + org_admin_ids = base_qs.filter( + Q(project__organizations__admins__in=[self.user]) | + (Q(project__isnull=True) & Q(job_type=PERM_INVENTORY_SCAN) & Q(inventory__organization__admins__in=[self.user])) + ) + + allowed_deploy = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY] + allowed_check = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY, PERM_INVENTORY_CHECK] + + team_ids = Team.objects.filter(users__in=[self.user]) + + # TODO: I think the below queries can be combined + deploy_permissions_ids = Permission.objects.filter( + Q(user=self.user) | Q(team_id__in=team_ids), + active=True, + permission_type__in=allowed_deploy, + ) + check_permissions_ids = Permission.objects.filter( + Q(user=self.user) | Q(team_id__in=team_ids), + active=True, + permission_type__in=allowed_check, + ) + + perm_deploy_ids = base_qs.filter( + job_type=PERM_INVENTORY_DEPLOY, + inventory__permissions__in=deploy_permissions_ids, + project__permissions__in=deploy_permissions_ids, + inventory__permissions__pk=F('project__permissions__pk'), + inventory_id__in=inventory_ids, + ) + + perm_check_ids = base_qs.filter( + job_type=PERM_INVENTORY_CHECK, + inventory__permissions__in=check_permissions_ids, + project__permissions__in=check_permissions_ids, + inventory__permissions__pk=F('project__permissions__pk'), + inventory_id__in=inventory_ids, + ) + + return base_qs.filter( + Q(id__in=org_admin_ids) | + Q(id__in=perm_deploy_ids) | + Q(id__in=perm_check_ids) + ) + + def can_read(self, obj): + # you can only see the job templates that you have permission to launch. + return self.can_start(obj, validate_license=False) + + def can_add(self, data): + ''' + a user can create a job template if they are a superuser, an org admin + of any org that the project is a member, or if they have user or team + based permissions tying the project to the inventory source for the + given action as well as the 'create' deploy permission. + Users who are able to create deploy jobs can also run normal and check (dry run) jobs. + ''' + if not data or '_method' in data: # So the browseable API will work? + return True + + if 'job_type' in data and data['job_type'] == PERM_INVENTORY_SCAN: + self.check_license(feature='system_tracking') + + if 'survey_enabled' in data and data['survey_enabled']: + self.check_license(feature='surveys') + + if self.user.is_superuser: + return True + + # If a credential is provided, the user should have read access to it. + credential_pk = get_pk_from_dict(data, 'credential') + if credential_pk: + credential = get_object_or_400(Credential, pk=credential_pk) + if not check_user_access(self.user, Credential, 'read', credential): + return False + + # If a cloud credential is provided, the user should have read access. + cloud_credential_pk = get_pk_from_dict(data, 'cloud_credential') + if cloud_credential_pk: + cloud_credential = get_object_or_400(Credential, + pk=cloud_credential_pk) + if not check_user_access(self.user, Credential, 'read', cloud_credential): + return False + + # Check that the given inventory ID is valid. + inventory_pk = get_pk_from_dict(data, 'inventory') + inventory = Inventory.objects.filter(id=inventory_pk) + if not inventory.exists(): + return False # Does this make sense? Maybe should check read access + + project_pk = get_pk_from_dict(data, 'project') + if 'job_type' in data and data['job_type'] == PERM_INVENTORY_SCAN: + if not project_pk and check_user_access(self.user, Organization, 'change', inventory[0].organization, None): + return True + elif not check_user_access(self.user, Organization, "change", inventory[0].organization, None): + return False + # If the user has admin access to the project (as an org admin), should + # be able to proceed without additional checks. + project = get_object_or_400(Project, pk=project_pk) + if check_user_access(self.user, Project, 'admin', project, None): + return True + + # Otherwise, check for explicitly granted permissions to create job templates + # for the project and inventory. + permission_qs = Permission.objects.filter( + Q(user=self.user) | Q(team__users__in=[self.user]), + inventory=inventory, + project=project, + active=True, + #permission_type__in=[PERM_INVENTORY_CHECK, PERM_INVENTORY_DEPLOY], + permission_type=PERM_JOBTEMPLATE_CREATE, + ) + if permission_qs.exists(): + return True + return False + + # job_type = data.get('job_type', None) + + # for perm in permission_qs: + # # if you have run permissions, you can also create check jobs + # if job_type == PERM_INVENTORY_CHECK: + # has_perm = True + # # you need explicit run permissions to make run jobs + # elif job_type == PERM_INVENTORY_DEPLOY and perm.permission_type == PERM_INVENTORY_DEPLOY: + # has_perm = True + # if not has_perm: + # return False + # return True + + # shouldn't really matter with permissions given, but make sure the user + # is also currently on the team in case they were added a per-user permission and then removed + # from the project. + #if not project.teams.filter(users__in=[self.user]).count(): + # return False + + def can_start(self, obj, validate_license=True): + # Check license. + if validate_license: + self.check_license() + if obj.job_type == PERM_INVENTORY_SCAN: + self.check_license(feature='system_tracking') + if obj.survey_enabled: + self.check_license(feature='surveys') + + # Super users can start any job + if self.user.is_superuser: + return True + # Check to make sure both the inventory and project exist + if obj.inventory is None: + return False + if obj.job_type == PERM_INVENTORY_SCAN: + if obj.project is None and check_user_access(self.user, Organization, 'change', obj.inventory.organization, None): + return True + if not check_user_access(self.user, Organization, 'change', obj.inventory.organization, None): + return False + if obj.project is None: + return False + # If the user has admin access to the project they can start a job + if check_user_access(self.user, Project, 'admin', obj.project, None): + return True + + # Otherwise check for explicitly granted permissions + permission_qs = Permission.objects.filter( + Q(user=self.user) | Q(team__users__in=[self.user]), + inventory=obj.inventory, + project=obj.project, + active=True, + permission_type__in=[PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_CHECK, PERM_INVENTORY_DEPLOY], + ) + + has_perm = False + for perm in permission_qs: + # If you have job template create permission that implies both CHECK and DEPLOY + # If you have DEPLOY permissions you can run both CHECK and DEPLOY + if perm.permission_type in [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY] and \ + obj.job_type == PERM_INVENTORY_DEPLOY: + has_perm = True + # If you only have CHECK permission then you can only run CHECK + if perm.permission_type in [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY, PERM_INVENTORY_CHECK] and \ + obj.job_type == PERM_INVENTORY_CHECK: + has_perm = True + + dep_access = check_user_access(self.user, Inventory, 'read', obj.inventory) and check_user_access(self.user, Project, 'read', obj.project) + return dep_access and has_perm + + def can_change(self, obj, data): + data_for_change = data + if data is not None: + data_for_change = dict(data) + for required_field in ('credential', 'cloud_credential', 'inventory', 'project'): + required_obj = getattr(obj, required_field, None) + if required_field not in data_for_change and required_obj is not None: + data_for_change[required_field] = required_obj.pk + return self.can_read(obj) and self.can_add(data_for_change) + + def can_delete(self, obj): + add_obj = dict(credential=obj.credential.id if obj.credential is not None else None, + cloud_credential=obj.cloud_credential.id if obj.cloud_credential is not None else None, + inventory=obj.inventory.id if obj.inventory is not None else None, + project=obj.project.id if obj.project is not None else None, + job_type=obj.job_type) + return self.can_add(add_obj) + +class JobAccess(BaseAccess): + + model = Job + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'job_template', 'inventory', + 'project', 'credential', 'cloud_credential', 'job_template') + qs = qs.prefetch_related('unified_job_template') + if self.user.is_superuser: + return qs + credential_ids = self.user.get_queryset(Credential) + base_qs = qs.filter( + credential_id__in=credential_ids, + ) + org_admin_ids = base_qs.filter( + Q(project__organizations__admins__in=[self.user]) | + (Q(project__isnull=True) & Q(job_type=PERM_INVENTORY_SCAN) & Q(inventory__organization__admins__in=[self.user])) + ) + + allowed_deploy = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY] + allowed_check = [PERM_JOBTEMPLATE_CREATE, PERM_INVENTORY_DEPLOY, PERM_INVENTORY_CHECK] + team_ids = Team.objects.filter(users__in=[self.user]) + + # TODO: I think the below queries can be combined + deploy_permissions_ids = Permission.objects.filter( + Q(user=self.user) | Q(team__in=team_ids), + active=True, + permission_type__in=allowed_deploy, + ) + check_permissions_ids = Permission.objects.filter( + Q(user=self.user) | Q(team__in=team_ids), + active=True, + permission_type__in=allowed_check, + ) + + perm_deploy_ids = base_qs.filter( + job_type=PERM_INVENTORY_DEPLOY, + inventory__permissions__in=deploy_permissions_ids, + project__permissions__in=deploy_permissions_ids, + inventory__permissions__pk=F('project__permissions__pk'), + ) + + perm_check_ids = base_qs.filter( + job_type=PERM_INVENTORY_CHECK, + inventory__permissions__in=check_permissions_ids, + project__permissions__in=check_permissions_ids, + inventory__permissions__pk=F('project__permissions__pk'), + ) + + return base_qs.filter( + Q(id__in=org_admin_ids) | + Q(id__in=perm_deploy_ids) | + Q(id__in=perm_check_ids) + ) + + def can_add(self, data): + if not data or '_method' in data: # So the browseable API will work? + return True + if not self.user.is_superuser: + return False + + + add_data = dict(data.items()) + + # If a job template is provided, the user should have read access to it. + job_template_pk = get_pk_from_dict(data, 'job_template') + if job_template_pk: + job_template = get_object_or_400(JobTemplate, pk=job_template_pk) + add_data.setdefault('inventory', job_template.inventory.pk) + add_data.setdefault('project', job_template.project.pk) + add_data.setdefault('job_type', job_template.job_type) + if job_template.credential: + add_data.setdefault('credential', job_template.credential.pk) + else: + job_template = None + + return True + + def can_change(self, obj, data): + return obj.status == 'new' and self.can_read(obj) and self.can_add(data) + + def can_delete(self, obj): + return self.can_read(obj) + + def can_start(self, obj): + self.check_license() + + # A super user can relaunch a job + if self.user.is_superuser: + return True + # If a user can launch the job template then they can relaunch a job from that + # job template + has_perm = False + if obj.job_template is not None and check_user_access(self.user, JobTemplate, 'start', obj.job_template): + has_perm = True + dep_access_inventory = check_user_access(self.user, Inventory, 'read', obj.inventory) + dep_access_project = obj.project is None or check_user_access(self.user, Project, 'read', obj.project) + return self.can_read(obj) and dep_access_inventory and dep_access_project and has_perm + + def can_cancel(self, obj): + return self.can_read(obj) and obj.can_cancel + +class SystemJobTemplateAccess(BaseAccess): + ''' + I can only see/manage System Job Templates if I'm a super user + ''' + + model = SystemJobTemplate + + def can_start(self, obj): + return self.can_read(obj) + +class SystemJobAccess(BaseAccess): + ''' + I can only see manage System Jobs if I'm a super user + ''' + model = SystemJob + +class AdHocCommandAccess(BaseAccess): + ''' + I can only see/run ad hoc commands when: + - I am a superuser. + - I am an org admin and have permission to read the credential. + - I am a normal user with a user/team permission that has at least read + permission on the inventory and the run_ad_hoc_commands flag set, and I + can read the credential. + ''' + model = AdHocCommand + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory', + 'credential') + if self.user.is_superuser: + return qs + + credential_ids = set(self.user.get_queryset(Credential).values_list('id', flat=True)) + team_ids = set(Team.objects.filter(active=True, users__in=[self.user]).values_list('id', flat=True)) + + permission_ids = set(Permission.objects.filter( + Q(user=self.user) | Q(team__in=team_ids), + active=True, + permission_type__in=PERMISSION_TYPES_ALLOWING_INVENTORY_READ, + run_ad_hoc_commands=True, + ).values_list('id', flat=True)) + + inventory_qs = self.user.get_queryset(Inventory) + inventory_qs = inventory_qs.filter(Q(permissions__in=permission_ids) | Q(organization__admins__in=[self.user])) + inventory_ids = set(inventory_qs.values_list('id', flat=True)) + + qs = qs.filter( + credential_id__in=credential_ids, + inventory_id__in=inventory_ids, + ) + return qs + + def can_add(self, data): + if not data or '_method' in data: # So the browseable API will work? + return True + + self.check_license() + + # If a credential is provided, the user should have read access to it. + credential_pk = get_pk_from_dict(data, 'credential') + if credential_pk: + credential = get_object_or_400(Credential, pk=credential_pk, active=True) + if not check_user_access(self.user, Credential, 'read', credential): + return False + + # Check that the user has the run ad hoc command permission on the + # given inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + if inventory_pk: + inventory = get_object_or_400(Inventory, pk=inventory_pk, active=True) + if not check_user_access(self.user, Inventory, 'run_ad_hoc_commands', inventory): + return False + + return True + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return self.can_read(obj) + + def can_start(self, obj): + return self.can_add({ + 'credential': obj.credential_id, + 'inventory': obj.inventory_id, + }) + + def can_cancel(self, obj): + return self.can_read(obj) and obj.can_cancel + +class AdHocCommandEventAccess(BaseAccess): + ''' + I can see ad hoc command event records whenever I can read both ad hoc + command and host. + ''' + + model = AdHocCommandEvent + + def get_queryset(self): + qs = self.model.objects.distinct() + qs = qs.select_related('ad_hoc_command', 'host') + + if self.user.is_superuser: + return qs + ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) + host_qs = self.user.get_queryset(Host) + qs = qs.filter(Q(host__isnull=True) | Q(host__in=host_qs), + ad_hoc_command__in=ad_hoc_command_qs) + return qs + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + +class JobHostSummaryAccess(BaseAccess): + ''' + I can see job/host summary records whenever I can read both job and host. + ''' + + model = JobHostSummary + + def get_queryset(self): + qs = self.model.objects.distinct() + qs = qs.select_related('job', 'job__job_template', 'host') + if self.user.is_superuser: + return qs + job_qs = self.user.get_queryset(Job) + host_qs = self.user.get_queryset(Host) + return qs.filter(job__in=job_qs, host__in=host_qs) + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + +class JobEventAccess(BaseAccess): + ''' + I can see job event records whenever I can read both job and host. + ''' + + model = JobEvent + + def get_queryset(self): + qs = self.model.objects.distinct() + qs = qs.select_related('job', 'job__job_template', 'host', 'parent') + qs = qs.prefetch_related('hosts', 'children') + + # Filter certain "internal" events generated by async polling. + qs = qs.exclude(event__in=('runner_on_ok', 'runner_on_failed'), + event_data__icontains='"ansible_job_id": "', + event_data__contains='"module_name": "async_status"') + + if self.user.is_superuser: + return qs + job_qs = self.user.get_queryset(Job) + host_qs = self.user.get_queryset(Host) + qs = qs.filter(Q(host__isnull=True) | Q(host__in=host_qs), + job__in=job_qs) + return qs + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + +class UnifiedJobTemplateAccess(BaseAccess): + ''' + I can see a unified job template whenever I can see the same project, + inventory source or job template. Unified job templates do not include + projects without SCM configured or inventory sources without a cloud + source. + ''' + + model = UnifiedJobTemplate + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + project_qs = self.user.get_queryset(Project).filter(scm_type__in=[s[0] for s in Project.SCM_TYPE_CHOICES]) + inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES) + job_template_qs = self.user.get_queryset(JobTemplate) + qs = qs.filter(Q(Project___in=project_qs) | + Q(InventorySource___in=inventory_source_qs) | + Q(JobTemplate___in=job_template_qs)) + qs = qs.select_related( + 'created_by', + 'modified_by', + #'project', + #'inventory', + #'credential', + #'cloud_credential', + 'next_schedule', + 'last_job', + 'current_job', + ) + # FIXME: Figure out how to do select/prefetch on related project/inventory/credential/cloud_credential. + return qs + +class UnifiedJobAccess(BaseAccess): + ''' + I can see a unified job whenever I can see the same project update, + inventory update or job. + ''' + + model = UnifiedJob + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + project_update_qs = self.user.get_queryset(ProjectUpdate) + inventory_update_qs = self.user.get_queryset(InventoryUpdate).filter(source__in=CLOUD_INVENTORY_SOURCES) + job_qs = self.user.get_queryset(Job) + ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) + system_job_qs = self.user.get_queryset(SystemJob) + qs = qs.filter(Q(ProjectUpdate___in=project_update_qs) | + Q(InventoryUpdate___in=inventory_update_qs) | + Q(Job___in=job_qs) | + Q(AdHocCommand___in=ad_hoc_command_qs) | + Q(SystemJob___in=system_job_qs)) + qs = qs.select_related( + 'created_by', + 'modified_by', + #'project', + #'inventory', + #'credential', + #'project___credential', + #'inventory_source___credential', + #'inventory_source___inventory', + #'job_template___inventory', + #'job_template___project', + #'job_template___credential', + #'job_template___cloud_credential', + ) + qs = qs.prefetch_related('unified_job_template') + # FIXME: Figure out how to do select/prefetch on related project/inventory/credential/cloud_credential. + return qs + +class ScheduleAccess(BaseAccess): + ''' + I can see a schedule if I can see it's related unified job, I can create them or update them if I have write access + ''' + + model = Schedule + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by') + qs = qs.prefetch_related('unified_job_template') + if self.user.is_superuser: + return qs + job_template_qs = self.user.get_queryset(JobTemplate) + inventory_source_qs = self.user.get_queryset(InventorySource) + project_qs = self.user.get_queryset(Project) + unified_qs = UnifiedJobTemplate.objects.filter(jobtemplate__in=job_template_qs) | \ + UnifiedJobTemplate.objects.filter(Q(project__in=project_qs)) | \ + UnifiedJobTemplate.objects.filter(Q(inventorysource__in=inventory_source_qs)) + return qs.filter(unified_job_template__in=unified_qs) + + def can_read(self, obj): + if self.user.is_superuser: + return True + if obj and obj.unified_job_template: + job_class = obj.unified_job_template + return check_user_access(self.user, type(job_class), 'read', obj.unified_job_template) + else: + return False + + def can_add(self, data): + if self.user.is_superuser: + return True + pk = get_pk_from_dict(data, 'unified_job_template') + obj = get_object_or_400(UnifiedJobTemplate, pk=pk) + if obj: + return check_user_access(self.user, type(obj), 'change', obj, None) + else: + return False + + def can_change(self, obj, data): + if self.user.is_superuser: + return True + if obj and obj.unified_job_template: + job_class = obj.unified_job_template + return check_user_access(self.user, type(job_class), 'change', job_class, None) + else: + return False + + def can_delete(self, obj): + if self.user.is_superuser: + return True + if obj and obj.unified_job_template: + job_class = obj.unified_job_template + return check_user_access(self.user, type(job_class), 'change', job_class, None) + else: + return False + +class ActivityStreamAccess(BaseAccess): + ''' + I can see activity stream events only when I have permission on all objects included in the event + ''' + + model = ActivityStream + + def get_queryset(self): + qs = self.model.objects.distinct() + qs = qs.select_related('actor') + qs = qs.prefetch_related('organization', 'user', 'inventory', 'host', 'group', 'inventory_source', + 'inventory_update', 'credential', 'team', 'project', 'project_update', + 'permission', 'job_template', 'job') + if self.user.is_superuser: + return qs + + user_admin_orgs = self.user.admin_of_organizations.all() + user_orgs = self.user.organizations.all() + + #Organization filter + qs = qs.filter(Q(organization__admins__in=[self.user]) | Q(organization__users__in=[self.user])) + + #User filter + qs = qs.filter(Q(user__pk=self.user.pk) | + Q(user__organizations__in=user_admin_orgs) | + Q(user__organizations__in=user_orgs)) + + #Inventory filter + inventory_qs = self.user.get_queryset(Inventory) + qs.filter(inventory__in=inventory_qs) + + #Host filter + qs.filter(host__inventory__in=inventory_qs) + + #Group filter + qs.filter(group__inventory__in=inventory_qs) + + #Inventory Source Filter + qs.filter(Q(inventory_source__inventory__in=inventory_qs) | + Q(inventory_source__group__inventory__in=inventory_qs)) + + #Inventory Update Filter + qs.filter(Q(inventory_update__inventory_source__inventory__in=inventory_qs) | + Q(inventory_update__inventory_source__group__inventory__in=inventory_qs)) + + #Credential Update Filter + qs.filter(Q(credential__user=self.user) | + Q(credential__user__organizations__in=user_admin_orgs) | + Q(credential__user__admin_of_organizations__in=user_admin_orgs) | + Q(credential__team__organization__in=user_admin_orgs) | + Q(credential__team__users__in=[self.user])) + + #Team Filter + qs.filter(Q(team__organization__admins__in=[self.user]) | + Q(team__users__in=[self.user])) + + #Project Filter + project_qs = self.user.get_queryset(Project) + qs.filter(project__in=project_qs) + + #Project Update Filter + qs.filter(project_update__project__in=project_qs) + + #Permission Filter + permission_qs = self.user.get_queryset(Permission) + qs.filter(permission__in=permission_qs) + + #Job Template Filter + jobtemplate_qs = self.user.get_queryset(JobTemplate) + qs.filter(job_template__in=jobtemplate_qs) + + #Job Filter + job_qs = self.user.get_queryset(Job) + qs.filter(job__in=job_qs) + + # Ad Hoc Command Filter + ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) + qs.filter(ad_hoc_command__in=ad_hoc_command_qs) + + # organization_qs = self.user.get_queryset(Organization) + # user_qs = self.user.get_queryset(User) + # inventory_qs = self.user.get_queryset(Inventory) + # host_qs = self.user.get_queryset(Host) + # group_qs = self.user.get_queryset(Group) + # inventory_source_qs = self.user.get_queryset(InventorySource) + # inventory_update_qs = self.user.get_queryset(InventoryUpdate) + # credential_qs = self.user.get_queryset(Credential) + # team_qs = self.user.get_queryset(Team) + # project_qs = self.user.get_queryset(Project) + # project_update_qs = self.user.get_queryset(ProjectUpdate) + # permission_qs = self.user.get_queryset(Permission) + # job_template_qs = self.user.get_queryset(JobTemplate) + # job_qs = self.user.get_queryset(Job) + # qs = qs.filter(Q(organization__in=organization_qs) | + # Q(user__in=user_qs) | + # Q(inventory__in=inventory_qs) | + # Q(host__in=host_qs) | + # Q(group__in=group_qs) | + # Q(inventory_source__in=inventory_source_qs) | + # Q(credential__in=credential_qs) | + # Q(team__in=team_qs) | + # Q(project__in=project_qs) | + # Q(project_update__in=project_update_qs) | + # Q(permission__in=permission_qs) | + # Q(job_template__in=job_template_qs) | + # Q(job__in=job_qs)) + return qs + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + +class CustomInventoryScriptAccess(BaseAccess): + + model = CustomInventoryScript + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + if not self.user.is_superuser: + qs = qs.filter(Q(organization__admins__in=[self.user]) | Q(organization__users__in=[self.user])) + return qs + + def can_read(self, obj): + if self.user.is_superuser: + return True + if not obj.active: + return False + return bool(obj.organization in self.user.organizations.all() or obj.organization in self.user.admin_of_organizations.all()) + + def can_add(self, data): + if self.user.is_superuser: + return True + return False + + def can_change(self, obj, data): + if self.user.is_superuser: + return True + return False + + def can_delete(self, obj): + if self.user.is_superuser: + return True + return False + + +class TowerSettingsAccess(BaseAccess): + ''' + - I can see settings when + - I am a super user + - I can edit settings when + - I am a super user + - I can clear settings when + - I am a super user + ''' + + model = TowerSettings + + 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_delete(self, obj): + return self.user.is_superuser + +register_access(User, UserAccess) +register_access(Organization, OrganizationAccess) +register_access(Inventory, InventoryAccess) +register_access(Host, HostAccess) +register_access(Group, GroupAccess) +register_access(InventorySource, InventorySourceAccess) +register_access(InventoryUpdate, InventoryUpdateAccess) +register_access(Credential, CredentialAccess) +register_access(Team, TeamAccess) +register_access(Project, ProjectAccess) +register_access(ProjectUpdate, ProjectUpdateAccess) +register_access(Permission, PermissionAccess) +register_access(JobTemplate, JobTemplateAccess) +register_access(Job, JobAccess) +register_access(JobHostSummary, JobHostSummaryAccess) +register_access(JobEvent, JobEventAccess) +register_access(SystemJobTemplate, SystemJobTemplateAccess) +register_access(SystemJob, SystemJobAccess) +register_access(AdHocCommand, AdHocCommandAccess) +register_access(AdHocCommandEvent, AdHocCommandEventAccess) +register_access(Schedule, ScheduleAccess) +register_access(UnifiedJobTemplate, UnifiedJobTemplateAccess) +register_access(UnifiedJob, UnifiedJobAccess) +register_access(ActivityStream, ActivityStreamAccess) +register_access(CustomInventoryScript, CustomInventoryScriptAccess) +register_access(TowerSettings, TowerSettingsAccess) diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index ddddb5c0a2..6b96bb943b 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -1,4 +1,5 @@ from collections import defaultdict +import _old_access as old_access def migrate_users(apps, schema_editor): migrations = list() @@ -52,7 +53,7 @@ def migrate_inventory(apps, schema_editor): for inventory in Inventory.objects.all(): teams, users = [], [] - for perm in Permission.objects.filter(inventory=inventory): + for perm in Permission.objects.filter(inventory=inventory, active=True): role = None execrole = None if perm.permission_type == 'admin': @@ -64,6 +65,10 @@ def migrate_inventory(apps, schema_editor): elif perm.permission_type == 'write': role = inventory.updater_role pass + elif perm.permission_type == 'check': + pass + elif perm.permission_type == 'run': + pass else: raise Exception('Unhandled permission type for inventory: %s' % perm.permission_type) if perm.run_ad_hoc_commands: @@ -122,14 +127,92 @@ def migrate_projects(apps, schema_editor): project.member_role.members.add(user) migrations[project.name]['users'].add(user) - for perm in Permission.objects.filter(project=project): + for perm in Permission.objects.filter(project=project, active=True): # All perms at this level just imply a user or team can read if perm.team: - team.member_role.children.add(project.member_role) - migrations[project.name]['teams'].add(team) + perm.team.member_role.children.add(project.member_role) + migrations[project.name]['teams'].add(perm.team) if perm.user: project.member_role.members.add(perm.user) migrations[project.name]['users'].add(perm.user) return migrations + + + +def migrate_job_templates(apps, schema_editor): + ''' + NOTE: This must be run after orgs, inventory, projects, credential, and + users have been migrated + ''' + + + ''' + I can see job templates when: + X I am a superuser. + - I can read the inventory, project and credential (which means I am an + org admin or member of a team with access to all of the above). + - I have permission explicitly granted to check/deploy with the inventory + and project. + + + #This does not mean I would be able to launch a job from the template or + #edit the template. + - access.py can_read for JobTemplate enforces that you can only + see it if you can launch it, so the above imply launch too + ''' + + + ''' + Tower administrators, organization administrators, and project + administrators, within a project under their purview, may create and modify + new job templates for that project. + + When editing a job template, they may select among the inventory groups and + credentials in the organization for which they have usage permissions, or + they may leave either blank to be selected at runtime. + + Additionally, they may specify one or more users/teams that have execution + permission for that job template, among the users/teams that are a member + of that project. + + That execution permission is valid irrespective of any explicit permissions + the user has or has not been granted to the inventory group or credential + specified in the job template. + + ''' + + migrations = defaultdict(lambda: defaultdict(set)) + + User = apps.get_model('auth', 'User') + JobTemplate = apps.get_model('main', 'JobTemplate') + Team = apps.get_model('main', 'Team') + Permission = apps.get_model('main', 'Permission') + + for jt in JobTemplate.objects.all(): + for team in Team.objects.all(): + if Permission.objects.filter( + team=team, + inventory=jt.inventory, + project=jt.project, + active=True, + permission_type__in=['create', 'check', 'run'] if jt.job_type == 'check' else ['create', 'run'] + ): + team.member_role.children.add(jt.executor_role); + migrations[jt.name]['teams'].add(team) + + + for user in User.objects.all(): + if jt.accessible_by(user, {'execute': True}): + # If the job template is already accessible by the user, because they + # are a sytem, organization, or project admin, then don't add an explicit + # role entry for them + continue + + if old_access.check_user_access(user, jt.__class__, 'start', jt, False): + jt.executor_role.members.add(user) + migrations[jt.name]['users'].add(user) + + + return migrations diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index d8f114bc40..3e0792dd65 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -195,9 +195,8 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): ) executor_role = ImplicitRoleField( role_name='Job Template Executor', - parent_role='project.auditor_role', resource_field='resource', - permissions = {'execute': True} + permissions = {'read': True, 'execute': True} ) @classmethod diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 8ff971c795..ddbad08bac 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -6,6 +6,7 @@ from awx.main.models.inventory import ( Group, ) from awx.main.models.projects import Project +from awx.main.models.jobs import JobTemplate from awx.main.models.organization import ( Organization, Team, @@ -23,6 +24,28 @@ def user(): return user return u +@pytest.fixture +def check_jobtemplate(project, inventory, credential): + return \ + JobTemplate.objects.create( + job_type='check', + project=project, + inventory=inventory, + credential=credential, + name='check-job-template' + ) + +@pytest.fixture +def deploy_jobtemplate(project, inventory, credential): + return \ + JobTemplate.objects.create( + job_type='run', + project=project, + inventory=inventory, + credential=credential, + name='deploy-job-template' + ) + @pytest.fixture def team(organization): return Team.objects.create(organization=organization, name='test-team') diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py new file mode 100644 index 0000000000..8792a1b8f4 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -0,0 +1,133 @@ +import pytest + +from awx.main.migrations import _rbac as rbac +from awx.main.models import Permission +from django.apps import apps + +@pytest.mark.django_db +def test_job_template_migration_check(deploy_jobtemplate, check_jobtemplate, user): + admin = user('admin', is_superuser=True) + joe = user('joe') + + + check_jobtemplate.project.organizations.all()[0].users.add(joe) + + Permission(user=joe, inventory=check_jobtemplate.inventory, permission_type='read').save() + Permission(user=joe, inventory=check_jobtemplate.inventory, + project=check_jobtemplate.project, permission_type='check').save() + + rbac.migrate_users(apps, None) + rbac.migrate_organization(apps, None) + rbac.migrate_projects(apps, None) + rbac.migrate_inventory(apps, None) + + assert check_jobtemplate.project.accessible_by(joe, {'read': True}) + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + + migrations = rbac.migrate_job_templates(apps, None) + + assert len(migrations[check_jobtemplate.name]['users']) == 1 + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is False + +@pytest.mark.django_db +def test_job_template_migration_deploy(deploy_jobtemplate, check_jobtemplate, user): + admin = user('admin', is_superuser=True) + joe = user('joe') + + + deploy_jobtemplate.project.organizations.all()[0].users.add(joe) + + Permission(user=joe, inventory=deploy_jobtemplate.inventory, permission_type='read').save() + Permission(user=joe, inventory=deploy_jobtemplate.inventory, + project=deploy_jobtemplate.project, permission_type='run').save() + + rbac.migrate_users(apps, None) + rbac.migrate_organization(apps, None) + rbac.migrate_projects(apps, None) + rbac.migrate_inventory(apps, None) + + assert deploy_jobtemplate.project.accessible_by(joe, {'read': True}) + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is False + + migrations = rbac.migrate_job_templates(apps, None) + + assert len(migrations[deploy_jobtemplate.name]['users']) == 1 + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is True + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + +@pytest.mark.django_db +def test_job_template_team_migration_check(deploy_jobtemplate, check_jobtemplate, organization, team, user): + admin = user('admin', is_superuser=True) + joe = user('joe') + team.users.add(joe) + team.organization = organization + team.save() + + check_jobtemplate.project.organizations.all()[0].users.add(joe) + + Permission(team=team, inventory=check_jobtemplate.inventory, permission_type='read').save() + Permission(team=team, inventory=check_jobtemplate.inventory, + project=check_jobtemplate.project, permission_type='check').save() + + rbac.migrate_users(apps, None) + rbac.migrate_team(apps, None) + rbac.migrate_organization(apps, None) + rbac.migrate_projects(apps, None) + rbac.migrate_inventory(apps, None) + + assert check_jobtemplate.project.accessible_by(joe, {'read': True}) + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + + migrations = rbac.migrate_job_templates(apps, None) + + assert len(migrations[check_jobtemplate.name]['users']) == 0 + assert len(migrations[check_jobtemplate.name]['teams']) == 1 + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is False + + +@pytest.mark.django_db +def test_job_template_team_deploy_migration(deploy_jobtemplate, check_jobtemplate, organization, team, user): + admin = user('admin', is_superuser=True) + joe = user('joe') + team.users.add(joe) + team.organization = organization + team.save() + + deploy_jobtemplate.project.organizations.all()[0].users.add(joe) + + Permission(team=team, inventory=deploy_jobtemplate.inventory, permission_type='read').save() + Permission(team=team, inventory=deploy_jobtemplate.inventory, + project=deploy_jobtemplate.project, permission_type='run').save() + + rbac.migrate_users(apps, None) + rbac.migrate_team(apps, None) + rbac.migrate_organization(apps, None) + rbac.migrate_projects(apps, None) + rbac.migrate_inventory(apps, None) + + assert deploy_jobtemplate.project.accessible_by(joe, {'read': True}) + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is False + + migrations = rbac.migrate_job_templates(apps, None) + + assert len(migrations[deploy_jobtemplate.name]['users']) == 0 + assert len(migrations[deploy_jobtemplate.name]['teams']) == 1 + assert deploy_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert deploy_jobtemplate.accessible_by(joe, {'execute': True}) is True + + assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True From c77620f1aee2484fd1139298f9e606d5c54d311a Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 11:49:54 -0500 Subject: [PATCH 3/5] Added support in ImplicitRoleField to handle following reverse m2m maps --- awx/main/fields.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 69a5cfa089..54efd655fc 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -211,7 +211,8 @@ class ImplicitRoleField(models.ForeignKey): first_field_name = field_name.split('.')[0] field = getattr(cls, first_field_name) - if type(field) is ReverseManyRelatedObjectsDescriptor: + if type(field) is ReverseManyRelatedObjectsDescriptor or \ + type(field) is ManyRelatedObjectsDescriptor: if found_m2m_field: # This limitation is due to a lack of understanding on my part, the # trouble being that I can't seem to get m2m_changed to call anything that @@ -227,14 +228,17 @@ class ImplicitRoleField(models.ForeignKey): found_m2m_field = True self.m2m_field_name = first_field_name self.m2m_field_attr = field_name.split('.',1)[1] - m2m_changed.connect(self.m2m_update, field.through) - if type(field) is ManyRelatedObjectsDescriptor: - raise Exception('ManyRelatedObjectsDescriptor references are currently unsupported ' + - '(but the reverse is, so supporting this is probably easy to add)): %s.%s' % - (cls.__name__, first_field_name)) + if type(field) is ReverseManyRelatedObjectsDescriptor: + m2m_changed.connect(self.m2m_update, field.through) + else: + m2m_changed.connect(self.m2m_update_related, field.related.through) + def m2m_update_related(self, **kwargs): + kwargs['reverse'] = not kwargs['reverse'] + self.m2m_update(**kwargs) + def m2m_update(self, sender, instance, action, reverse, model, pk_set, **kwargs): if action == 'post_add' or action == 'pre_remove': if reverse: From e2a428b9f5f0ecf99c6b8611616a8ee2b7d6b1d0 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 12:35:04 -0500 Subject: [PATCH 4/5] Removed resource_field ImplicitRoleField We just now assume that this field is always named 'resource' Completes functionality of #926, documentation next --- awx/main/fields.py | 11 ++++------- awx/main/models/credential.py | 2 -- awx/main/models/inventory.py | 6 ------ awx/main/models/jobs.py | 3 --- awx/main/models/organization.py | 6 ------ awx/main/models/projects.py | 4 ---- 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 54efd655fc..df0da42538 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -91,9 +91,8 @@ class ImplicitResourceField(models.ForeignKey): class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): """Descriptor Implict Role Fields. Auto-creates the appropriate role entry on first access""" - def __init__(self, role_name, resource_field, permissions, parent_role, *args, **kwargs): + def __init__(self, role_name, permissions, parent_role, *args, **kwargs): self.role_name = role_name - self.resource_field = resource_field self.permissions = permissions self.parent_role = parent_role @@ -143,10 +142,10 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): setattr(instance, self.field.name, role) instance.save(update_fields=[self.field.name,]) - if self.resource_field and self.permissions: + if self.permissions is not None: permissions = RolePermission( role=role, - resource=getattr(instance, self.resource_field) + resource=instance.resource ) if 'all' in self.permissions and self.permissions['all']: @@ -170,9 +169,8 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): class ImplicitRoleField(models.ForeignKey): """Implicitly creates a role entry for a resource""" - def __init__(self, role_name=None, resource_field=None, permissions=None, parent_role=None, *args, **kwargs): + def __init__(self, role_name=None, permissions=None, parent_role=None, *args, **kwargs): self.role_name = role_name - self.resource_field = resource_field self.permissions = permissions self.parent_role = parent_role @@ -187,7 +185,6 @@ class ImplicitRoleField(models.ForeignKey): self.name, ImplicitRoleDescriptor( self.role_name, - self.resource_field, self.permissions, self.parent_role, self diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 5d1c0cab96..cf2dd262ed 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -158,12 +158,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): owner_role = ImplicitRoleField( role_name='Credential Owner', parent_role='team.admin_role', - resource_field='resource', permissions = {'all': True} ) usage_role = ImplicitRoleField( role_name='Credential User', - resource_field='resource', parent_role= 'team.member_role', permissions = {'use': True} ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index adaca41184..17b51ca923 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -96,13 +96,11 @@ class Inventory(CommonModel, ResourceMixin): admin_role = ImplicitRoleField( role_name='Inventory Administrator', parent_role='organization.admin_role', - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Inventory Auditor', parent_role='organization.auditor_role', - resource_field='resource', permissions = {'read': True} ) updater_role = ImplicitRoleField( @@ -545,25 +543,21 @@ class Group(CommonModelNameNotUnique, ResourceMixin): admin_role = ImplicitRoleField( role_name='Inventory Group Administrator', parent_role=['inventory.admin_role', 'parents.admin_role'], - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Inventory Group Auditor', parent_role=['inventory.auditor_role', 'parents.auditor_role'], - resource_field='resource', permissions = {'read': True} ) updater_role = ImplicitRoleField( role_name='Inventory Group Updater', parent_role=['inventory.updater_role', 'parents.updater_role'], - resource_field='resource', permissions = {'read': True, 'write': True, 'create': True, 'use': True}, ) executor_role = ImplicitRoleField( role_name='Inventory Group Executor', parent_role=['inventory.executor_role', 'parents.executor_role'], - resource_field='resource', permissions = {'read':True, 'execute':True}, ) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 3e0792dd65..c055ec6ed4 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -184,18 +184,15 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): admin_role = ImplicitRoleField( role_name='Job Template Administrator', parent_role='project.admin_role', - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Job Template Auditor', parent_role='project.auditor_role', - resource_field='resource', permissions = {'read': True} ) executor_role = ImplicitRoleField( role_name='Job Template Executor', - resource_field='resource', permissions = {'read': True, 'execute': True} ) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 2b974a6317..b08f068060 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -51,18 +51,15 @@ class Organization(CommonModel, ResourceMixin): admin_role = ImplicitRoleField( role_name='Organization Administrator', parent_role='singleton:System Administrator', - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Organization Auditor', parent_role='singleton:System Auditor', - resource_field='resource', permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Organization Member', - resource_field='resource', permissions = {'read': True} ) @@ -110,19 +107,16 @@ class Team(CommonModelNameNotUnique, ResourceMixin): admin_role = ImplicitRoleField( role_name='Team Administrator', parent_role='organization.admin_role', - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Team Auditor', parent_role='organization.auditor_role', - resource_field='resource', permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Team Member', parent_role='admin_role', - resource_field='resource', permissions = {'read':True}, ) diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 0d3f628575..d0fd122584 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -210,24 +210,20 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): admin_role = ImplicitRoleField( role_name='Project Administrator', parent_role='organizations.admin_role', - resource_field='resource', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Project Auditor', parent_role='organizations.auditor_role', - resource_field='resource', permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Project Member', - resource_field='resource', permissions = {'read': True} ) scm_update_role = ImplicitRoleField( role_name='Project Updater', parent_role='admin_role', - resource_field='resource', permissions = {'scm_update': True} ) From aafe521986144bdf7ab1e75ad12e671e158eb4de Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 12:55:44 -0500 Subject: [PATCH 5/5] doc: ImplicitRoleField after the elimination of resource_field Completes #926 --- docs/rbac.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/rbac.md b/docs/rbac.md index 2b0304dd5d..ca7942d16f 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -103,7 +103,16 @@ The `singleton` static method is a helper method on the `Role` model that helps `role_name` is the display name of the role. This is useful when generating reports or looking the results of queries. -`permissions` is a dictionary of set permissions that a user with this role will gain to your `Resource`. A permission defaults to `False` if not explicitly provided. Below is a list of available permissions. The special permission `all` is a shortcut for generating a dict with all of the explicit permissions listed below set to `True`. +`permissions` can be used when the model that contains the +`ImplicitRoleField` utilizs the `ResourceMixin`. When present, a +`RolePermission` entry will be automatically created to grant the specified +permissions on the resource to the role defined by the `ImplicitRoleField`. + +This field should be specified as a dictionary of permissions you wish to +automatically grant. Below is a list of available permissions. The special +permission `all` is a shortcut for generating a dict with all of the explicit +permissions listed below set to `True`. Note that permissions default to +`False` if not explicitly provided. ```python # Available Permissions