From 00619b7e36573b003a653b580ffd3e8121c79e5c Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 27 Jan 2016 10:42:45 -0500 Subject: [PATCH 001/297] Initial RBAC field and model definitions --- awx/main/fields.py | 119 ++++++++++++++++++++++++++- awx/main/models/__init__.py | 1 + awx/main/models/rbac.py | 158 ++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 awx/main/models/rbac.py diff --git a/awx/main/fields.py b/awx/main/fields.py index 85c8c4ff5f..545a773108 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -4,8 +4,11 @@ # Django from django.db import models from django.db.models.fields.related import SingleRelatedObjectDescriptor +from django.db.models.fields.related import ForeignRelatedObjectsDescriptor +from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor -__all__ = ['AutoOneToOneField'] + +__all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField'] # Based on AutoOneToOneField from django-annoying: # https://bitbucket.org/offline/django-annoying/src/a0de8b294db3/annoying/fields.py @@ -32,3 +35,117 @@ class AutoOneToOneField(models.OneToOneField): def contribute_to_related_class(self, cls, related): setattr(cls, related.get_accessor_name(), AutoSingleRelatedObjectDescriptor(related)) + + + + + +def resolve_field(obj, field): + for f in field.split('.'): + obj = getattr(obj, f) + return obj + +class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): + """Descriptor for access to the object from its related class.""" + + def __init__(self, parent_resource, *args, **kwargs): + self.parent_resource = parent_resource + super(ResourceFieldDescriptor, self).__init__(*args, **kwargs) + + def __get__(self, instance, instance_type=None): + resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) + if resource: + return resource + resource = Resource._default_manager.create() + if self.parent_resource: + resource.parent = resolve_field(instance, self.parent_resource) + resource.save() + setattr(instance, self.field.name, resource) + instance.save(update_fields=[self.field.name,]) + return resource; + + +class ImplicitResourceField(models.ForeignKey): + """Creates an associated resource object if one doesn't already exist""" + + def __init__(self, parent_resource=None, *args, **kwargs): + self.parent_resource = parent_resource + kwargs.setdefault('to', 'Resource') + kwargs.setdefault('related_name', '+') + kwargs.setdefault('null', 'True') + super(ImplicitResourceField, self).__init__(*args, **kwargs) + + def contribute_to_class(self, cls, name): + super(ImplicitResourceField, self).contribute_to_class(cls, name); + setattr(cls, self.name, ResourceFieldDescriptor(self.parent_resource, self)) + + + +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): + self.role_name = role_name + self.resource_field = resource_field + self.permissions = permissions + self.parent_role = parent_role + + super(ImplicitRoleDescriptor, self).__init__(*args, **kwargs) + + def __get__(self, instance, instance_type=None): + role = super(ImplicitRoleDescriptor, self).__get__(instance, instance_type) + if role: + return role + + if not self.role_name: + raise FieldError('Implicit role missing `role_name`') + if not self.resource_field: + raise FieldError('Implicit role missing `resource_field` specification') + if not self.permissions: + raise FieldError('Implicit role missing `permissions`') + + role = Role._default_manager.create(name=self.role_name) + role.save() + if self.parent_role: + role.parents.add(resolve_field(instance, self.parent_role)) + setattr(instance, self.field.name, role) + instance.save(update_fields=[self.field.name,]) + + permissions = RolePermission( + role=role, + resource=getattr(instance, self.resource_field) + ) + for k,v in self.permissions.items(): + setattr(permissions, k, v) + permissions.save() + + return role; + + +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): + self.role_name = role_name + self.resource_field = resource_field + self.permissions = permissions + self.parent_role = parent_role + + kwargs.setdefault('to', 'Role') + kwargs.setdefault('related_name', '+') + kwargs.setdefault('null', 'True') + super(ImplicitRoleField, self).__init__(*args, **kwargs) + + def contribute_to_class(self, cls, name): + super(ImplicitRoleField, self).contribute_to_class(cls, name); + setattr(cls, + self.name, + ImplicitRoleDescriptor( + self.role_name, + self.resource_field, + self.permissions, + self.parent_role, + self + ) + ) + diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 23cf591e6b..ce01f5f51e 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -17,6 +17,7 @@ from awx.main.models.schedules import * # noqa 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 # 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 new file mode 100644 index 0000000000..73deeaf303 --- /dev/null +++ b/awx/main/models/rbac.py @@ -0,0 +1,158 @@ +# Copyright (c) 2016 Ansible, Inc. +# All Rights Reserved. + +# Python +import logging + +# Django +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import FieldError + +# AWX +from awx.main.models.base import * # noqa +from awx.main.fields import * # noqa + +__all__ = ['Role', 'RolePermission', 'Resource'] + +logger = logging.getLogger('awx.main.models.rbac') + + + +class Role(CommonModelNameNotUnique): + ''' + Role model + ''' + + class Meta: + app_label = 'main' + verbose_name_plural = _('roles') + db_table = 'main_rbac_roles' + + parents = models.ManyToManyField('Role', related_name='children') + members = models.ManyToManyField('auth.User', related_name='roles') + + def save(self, *args, **kwargs): + super(Role, self).save(*args, **kwargs) + self.rebuild_role_hierarchy_cache() + + def rebuild_role_hierarchy_cache(self): + 'Rebuilds the associated entries in the RoleHierarchy model' + + # Compute what our hierarchy should be. (Note: this depends on our + # parent's cached hierarchy being correct) + parent_ids = set([parent.id for parent in self.parents.all()]) + actual_ancestors = set([r.ancestor.id for r in RoleHierarchy.objects.filter(role__id__in=parent_ids)]) + actual_ancestors.add(self.id) + + # Compute what we have stored + stored_ancestors = set([r.ancestor.id for r in RoleHierarchy.objects.filter(role__id=self.id)]) + + # If it differs, update, and then update all of our children + if actual_ancestors != stored_ancestors: + RoleHierarchy.objects.filter(role__id=self.id).delete() + for id in actual_ancestors: + rh = RoleHierarchy(role=self, ancestor=Role.objects.get(id=id)) + rh.save() + for child in self.children.all(): + child.rebuild_role_hierarchy_cache(); + + +class RoleHierarchy(CreatedModifiedModel): + ''' + Stores a flattened relation map of all roles in the system for easy joining + ''' + + class Meta: + app_label = 'main' + verbose_name_plural = _('role_ancestors') + db_table = 'main_rbac_role_hierarchy' + + role = models.ForeignKey('Role', related_name='+', on_delete=models.CASCADE) + ancestor = models.ForeignKey('Role', related_name='+', on_delete=models.CASCADE) + + +class Resource(CommonModelNameNotUnique): + ''' + Role model + ''' + + class Meta: + app_label = 'main' + verbose_name_plural = _('resources') + db_table = 'main_rbac_resources' + + parent = models.ForeignKey('Resource', related_name='children', null=True, default=None) + + def save(self, *args, **kwargs): + super(Resource, self).save(*args, **kwargs) + self.rebuild_resource_hierarchy_cache() + + def rebuild_resource_hierarchy_cache(self): + 'Rebuilds the associated entries in the ResourceHierarchy model' + + # Compute what our hierarchy should be. (Note: this depends on our + # parent's cached hierarchy being correct) + actual_ancestors = set() + if self.parent: + actual_ancestors = set([r.ancestor.id for r in ResourceHierarchy.objects.filter(resource__id=self.parent.id)]) + actual_ancestors.add(self.id) + + # Compute what we have stored + stored_ancestors = set([r.ancestor.id for r in ResourceHierarchy.objects.filter(resource__id=self.id)]) + + # If it differs, update, and then update all of our children + if actual_ancestors != stored_ancestors: + ResourceHierarchy.objects.filter(resource__id=self.id).delete() + for id in actual_ancestors: + rh = ResourceHierarchy(resource=self, ancestor=Resource.objects.get(id=id)) + rh.save() + for child in self.children.all(): + child.rebuild_resource_hierarchy_cache(); + + + +class ResourceHierarchy(CreatedModifiedModel): + ''' + Stores a flattened relation map of all resources in the system for easy joining + ''' + + class Meta: + app_label = 'main' + verbose_name_plural = _('resource_ancestors') + db_table = 'main_rbac_resource_hierarchy' + + resource = models.ForeignKey('Resource', related_name='+', on_delete=models.CASCADE) + ancestor = models.ForeignKey('Resource', related_name='+', on_delete=models.CASCADE) + + +class RolePermission(CreatedModifiedModel): + ''' + Defines the permissions a role has + ''' + + class Meta: + app_label = 'main' + verbose_name_plural = _('permissions') + db_table = 'main_rbac_permissions' + + role = models.ForeignKey( + Role, + null=False, + on_delete=models.CASCADE, + related_name='permissions', + ) + resource = models.ForeignKey( + Resource, + null=False, + on_delete=models.CASCADE, + related_name='permissions', + ) + create = models.BooleanField(default = False) + read = models.BooleanField(default = False) + write = models.BooleanField(default = False) + update = models.BooleanField(default = False) + delete = models.BooleanField(default = False) + scm_update = models.BooleanField(default = False) + + From fae9ef3d65f84dfae2253479251142aa1812852c Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 27 Jan 2016 11:00:29 -0500 Subject: [PATCH 002/297] flake8 corrections --- awx/main/fields.py | 15 +++++++++------ awx/main/models/rbac.py | 5 ++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 545a773108..18c227c7bc 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -4,8 +4,11 @@ # Django from django.db import models from django.db.models.fields.related import SingleRelatedObjectDescriptor -from django.db.models.fields.related import ForeignRelatedObjectsDescriptor from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor +from django.core.exceptions import FieldError + +# AWX +from awx.main.models.rbac import Resource, RolePermission, Role __all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField'] @@ -62,7 +65,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): resource.save() setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) - return resource; + return resource class ImplicitResourceField(models.ForeignKey): @@ -76,7 +79,7 @@ class ImplicitResourceField(models.ForeignKey): super(ImplicitResourceField, self).__init__(*args, **kwargs) def contribute_to_class(self, cls, name): - super(ImplicitResourceField, self).contribute_to_class(cls, name); + super(ImplicitResourceField, self).contribute_to_class(cls, name) setattr(cls, self.name, ResourceFieldDescriptor(self.parent_resource, self)) @@ -119,7 +122,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): setattr(permissions, k, v) permissions.save() - return role; + return role class ImplicitRoleField(models.ForeignKey): @@ -137,7 +140,7 @@ class ImplicitRoleField(models.ForeignKey): super(ImplicitRoleField, self).__init__(*args, **kwargs) def contribute_to_class(self, cls, name): - super(ImplicitRoleField, self).contribute_to_class(cls, name); + super(ImplicitRoleField, self).contribute_to_class(cls, name) setattr(cls, self.name, ImplicitRoleDescriptor( @@ -147,5 +150,5 @@ class ImplicitRoleField(models.ForeignKey): self.parent_role, self ) - ) + ) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 73deeaf303..5d7226a73e 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -7,7 +7,6 @@ import logging # Django from django.db import models from django.utils.translation import ugettext_lazy as _ -from django.core.exceptions import FieldError # AWX from awx.main.models.base import * # noqa @@ -55,7 +54,7 @@ class Role(CommonModelNameNotUnique): rh = RoleHierarchy(role=self, ancestor=Role.objects.get(id=id)) rh.save() for child in self.children.all(): - child.rebuild_role_hierarchy_cache(); + child.rebuild_role_hierarchy_cache() class RoleHierarchy(CreatedModifiedModel): @@ -108,7 +107,7 @@ class Resource(CommonModelNameNotUnique): rh = ResourceHierarchy(resource=self, ancestor=Resource.objects.get(id=id)) rh.save() for child in self.children.all(): - child.rebuild_resource_hierarchy_cache(); + child.rebuild_resource_hierarchy_cache() From 014b97003013f2e3817adb45c5ae7693818fb28e Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 27 Jan 2016 14:58:50 -0500 Subject: [PATCH 003/297] Automatically rebuild our role hierarchy when our m2m map is updated --- awx/main/models/rbac.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 5d7226a73e..2ec6ac0c3b 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -7,17 +7,21 @@ import logging # Django from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed # AWX from awx.main.models.base import * # noqa from awx.main.fields import * # noqa -__all__ = ['Role', 'RolePermission', 'Resource'] +__all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy', 'ResourceHierarchy'] logger = logging.getLogger('awx.main.models.rbac') +def rebuild_role_hierarchy_cache(sender, **kwargs): + kwargs['instance'].rebuild_role_hierarchy_cache() + class Role(CommonModelNameNotUnique): ''' Role model @@ -56,6 +60,8 @@ class Role(CommonModelNameNotUnique): for child in self.children.all(): child.rebuild_role_hierarchy_cache() +m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) + class RoleHierarchy(CreatedModifiedModel): ''' From 68d82996891dd0c940736cf590a55df205bf1b4d Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 28 Jan 2016 15:51:47 -0500 Subject: [PATCH 004/297] Switched from booleans to integers for permissions flags This is so that our permissions aggregation query can use MAX(column) which exists and works in both postgres and sqlite, as opposed to having some conditional aggregate function that we use depending on our backend. --- awx/main/models/rbac.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 2ec6ac0c3b..fb8a6f12ca 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -153,11 +153,11 @@ class RolePermission(CreatedModifiedModel): on_delete=models.CASCADE, related_name='permissions', ) - create = models.BooleanField(default = False) - read = models.BooleanField(default = False) - write = models.BooleanField(default = False) - update = models.BooleanField(default = False) - delete = models.BooleanField(default = False) - scm_update = models.BooleanField(default = False) + create = models.IntegerField(default = 0) + read = models.IntegerField(default = 0) + write = models.IntegerField(default = 0) + update = models.IntegerField(default = 0) + delete = models.IntegerField(default = 0) + scm_update = models.IntegerField(default = 0) From 6dad0406b8fdbdde85c0751940b95c8d22d3c5ee Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 28 Jan 2016 16:58:19 -0500 Subject: [PATCH 005/297] Initial implicit role / resource field additions into models "Completes" #731 until we find out what I missed --- awx/main/fields.py | 56 ++++++++++++++++++++++++--------- awx/main/models/credential.py | 22 +++++++++++++ awx/main/models/inventory.py | 56 ++++++++++++++++++++++++++++++++- awx/main/models/jobs.py | 20 ++++++++++++ awx/main/models/organization.py | 39 ++++++++++++++++++++++- awx/main/models/projects.py | 34 ++++++++++++++++++++ awx/main/models/rbac.py | 3 +- 7 files changed, 213 insertions(+), 17 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 18c227c7bc..d6a7dcf4d4 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -45,7 +45,8 @@ class AutoOneToOneField(models.OneToOneField): def resolve_field(obj, field): for f in field.split('.'): - obj = getattr(obj, f) + if obj: + obj = getattr(obj, f) return obj class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): @@ -61,7 +62,16 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): return resource resource = Resource._default_manager.create() if self.parent_resource: - resource.parent = resolve_field(instance, self.parent_resource) + # Take first non null parent resource + parent = None + if type(self.parent_resource) is list: + for path in self.parent_resource: + parent = resolve_field(instance, path) + if parent: + break + else: + parent = resolve_field(instance, self.parent_resource) + resource.parent = parent resource.save() setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) @@ -102,25 +112,43 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if not self.role_name: raise FieldError('Implicit role missing `role_name`') - if not self.resource_field: - raise FieldError('Implicit role missing `resource_field` specification') - if not self.permissions: - raise FieldError('Implicit role missing `permissions`') role = Role._default_manager.create(name=self.role_name) role.save() if self.parent_role: - role.parents.add(resolve_field(instance, self.parent_role)) + # Add all non-null parent roles as parents + if type(self.parent_role) is list: + for path in self.parent_role: + parent = resolve_field(instance, path) + if parent: + role.parents.add(parent) + else: + parent = resolve_field(instance, self.parent_role) + if parent: + role.parents.add(parent) setattr(instance, self.field.name, role) instance.save(update_fields=[self.field.name,]) - permissions = RolePermission( - role=role, - resource=getattr(instance, self.resource_field) - ) - for k,v in self.permissions.items(): - setattr(permissions, k, v) - permissions.save() + if self.resource_field and self.permissions: + permissions = RolePermission( + role=role, + resource=getattr(instance, self.resource_field) + ) + + if 'all' in self.permissions and self.permissions['all']: + del self.permissions['all'] + self.permissions['create'] = True + self.permissions['read'] = True + self.permissions['write'] = True + self.permissions['update'] = True + self.permissions['delete'] = True + self.permissions['scm_update'] = True + self.permissions['use'] = True + self.permissions['execute'] = True + + for k,v in self.permissions.items(): + setattr(permissions, k, v) + permissions.save() return role diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 82e0f576e1..3f4e6bf27e 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -11,6 +11,7 @@ from django.core.exceptions import ValidationError, NON_FIELD_ERRORS from django.core.urlresolvers import reverse # AWX +from awx.main.fields import ImplicitResourceField, ImplicitRoleField from awx.main.constants import CLOUD_PROVIDERS from awx.main.utils import decrypt_field from awx.main.models.base import * # noqa @@ -153,6 +154,27 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): default='', help_text=_('Vault password (or "ASK" to prompt the user).'), ) + resource = ImplicitResourceField( + parent_resource=[ + 'user.resource', + 'team.resource' + ] + ) + owner_role = ImplicitRoleField( + role_name='Credential Owner', + parent_role=[ + 'user.user_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 = { 'usage': True } + ) @property def needs_ssh_password(self): diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 37b1dafc4b..a4c593e5d2 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -18,7 +18,7 @@ from django.utils.timezone import now # AWX from awx.main.constants import CLOUD_PROVIDERS -from awx.main.fields import AutoOneToOneField +from awx.main.fields import AutoOneToOneField, ImplicitResourceField, ImplicitRoleField from awx.main.managers import HostManager from awx.main.models.base import * # noqa from awx.main.models.jobs import Job @@ -92,6 +92,21 @@ class Inventory(CommonModel): editable=False, help_text=_('Number of external inventory sources in this inventory with failures.'), ) + resource = ImplicitResourceField( + parent_resource='organization.resource' + ) + 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 } + ) def get_absolute_url(self): return reverse('api:inventory_detail', args=(self.pk,)) @@ -523,6 +538,21 @@ class Group(CommonModelNameNotUnique): editable=False, help_text=_('Inventory source(s) that created or modified this group.'), ) + resource = ImplicitResourceField( + parent_resource='inventory.resource' + ) + admin_role = ImplicitRoleField( + role_name='Inventory Group Administrator', + parent_role='inventory.admin_role', + resource_field='resource', + permissions = { 'all': True } + ) + auditor_role = ImplicitRoleField( + role_name='Inventory Group Auditor', + parent_role='inventory.auditor_role', + resource_field='resource', + permissions = { 'read': True } + ) def __unicode__(self): return self.name @@ -1093,6 +1123,30 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): update_cache_timeout = models.PositiveIntegerField( default=0, ) + resource = ImplicitResourceField( + parent_resource=[ + 'group.resource', + 'inventory.resource' + ] + ) + admin_role = ImplicitRoleField( + role_name='Inventory Group Administrator', + parent_role=[ + 'group.admin_role', + 'inventory.admin_role', + ], + resource_field='resource', + permissions = { 'all': True } + ) + auditor_role = ImplicitRoleField( + role_name='Inventory Group Auditor', + parent_role=[ + 'group.auditor_role', + 'inventory.auditor_role', + ], + resource_field='resource', + permissions = { 'read': True } + ) @classmethod def _get_unified_job_class(cls): diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 833d20a9b4..019be64abd 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -26,6 +26,7 @@ from awx.main.utils import decrypt_field, ignore_inventory_computed_fields from awx.main.utils import emit_websocket_notification from awx.main.redact import PlainTextCleaner from awx.main.conf import tower_settings +from awx.main.fields import ImplicitResourceField, ImplicitRoleField logger = logging.getLogger('awx.main.models.jobs') @@ -178,6 +179,25 @@ class JobTemplate(UnifiedJobTemplate, JobOptions): blank=True, default={}, ) + resource = ImplicitResourceField() + 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', + parent_role='project.auditor_role', + resource_field='resource', + permissions = { 'execute': True } + ) @classmethod def _get_unified_job_class(cls): diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index c22b907082..fc74aabd58 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -16,7 +16,7 @@ from django.utils.timezone import now as tz_now from django.utils.translation import ugettext_lazy as _ # AWX -from awx.main.fields import AutoOneToOneField +from awx.main.fields import AutoOneToOneField, ImplicitResourceField, ImplicitRoleField from awx.main.models.base import * # noqa from awx.main.conf import tower_settings @@ -42,11 +42,27 @@ class Organization(CommonModel): 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, related_name='organizations', ) + resource = ImplicitResourceField() + admin_role = ImplicitRoleField( + role_name='Organization Administrator', + resource_field='resource', + permissions = { 'all': True } + ) + auditor_role = ImplicitRoleField( + role_name='Organization Auditor', + resource_field='resource', + permissions = { 'read': True } + ) + def get_absolute_url(self): return reverse('api:organization_detail', args=(self.pk,)) @@ -88,6 +104,23 @@ class Team(CommonModelNameNotUnique): blank=True, related_name='teams', ) + resource = ImplicitResourceField() + 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', + ) def get_absolute_url(self): return reverse('api:team_detail', args=(self.pk,)) @@ -103,6 +136,10 @@ class Team(CommonModelNameNotUnique): class Permission(CommonModelNameNotUnique): ''' A permission allows a user, project, or team to be able to use an inventory source. + + NOTE: This class is deprecated, permissions and access is to be handled by + our new RBAC system. This class should be able to be safely removed after a 3.0.0 + migration. - anoek 2016-01-28 ''' class Meta: diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 2fa6512ca0..172fd09d6f 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -22,6 +22,7 @@ from awx.main.models.base import * # noqa from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * # noqa from awx.main.utils import update_scm_url +from awx.main.fields import ImplicitResourceField, ImplicitRoleField __all__ = ['Project', 'ProjectUpdate'] @@ -194,6 +195,14 @@ class Project(UnifiedJobTemplate, ProjectOptions): 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, @@ -205,6 +214,31 @@ class Project(UnifiedJobTemplate, ProjectOptions): default=0, blank=True, ) + resource = ImplicitResourceField() + admin_role = ImplicitRoleField( + role_name='Project Administrator', + parent_role='organization.admin_role', + resource_field='resource', + permissions = { 'all': True } + ) + auditor_role = ImplicitRoleField( + role_name='Project Auditor', + parent_role='organization.auditor_role', + resource_field='resource', + permissions = { 'read': True } + ) + member_role = ImplicitRoleField( + role_name='Project Member', + parent_role='admin', + resource_field='resource', + permissions = { 'usage': True } + ) + scm_update_role = ImplicitRoleField( + role_name='Project Updater', + parent_role='admin', + resource_field='resource', + permissions = { 'scm_update': True } + ) @classmethod def _get_unified_job_class(cls): diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index fb8a6f12ca..36db604997 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -11,7 +11,6 @@ from django.db.models.signals import pre_save, post_save, pre_delete, post_delet # AWX from awx.main.models.base import * # noqa -from awx.main.fields import * # noqa __all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy', 'ResourceHierarchy'] @@ -158,6 +157,8 @@ class RolePermission(CreatedModifiedModel): write = models.IntegerField(default = 0) update = models.IntegerField(default = 0) delete = models.IntegerField(default = 0) + execute = models.IntegerField(default = 0) scm_update = models.IntegerField(default = 0) + use = models.IntegerField(default = 0) From 5b50ebb8daeeadcf816388576a9da6135cdc05ec Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 13:18:32 -0500 Subject: [PATCH 006/297] Added a ResourceMixin to be added to any model that is a "Resource" Also added initial permissions checking and accessible object methods to the mixin --- awx/main/models/credential.py | 11 +-- awx/main/models/inventory.py | 21 ++--- awx/main/models/jobs.py | 7 +- awx/main/models/mixins.py | 136 ++++++++++++++++++++++++++++++++ awx/main/models/organization.py | 9 +-- awx/main/models/projects.py | 6 +- 6 files changed, 155 insertions(+), 35 deletions(-) create mode 100644 awx/main/models/mixins.py diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 3f4e6bf27e..6e16851145 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -11,15 +11,16 @@ from django.core.exceptions import ValidationError, NON_FIELD_ERRORS from django.core.urlresolvers import reverse # AWX -from awx.main.fields import ImplicitResourceField, ImplicitRoleField +from awx.main.fields import ImplicitRoleField from awx.main.constants import CLOUD_PROVIDERS from awx.main.utils import decrypt_field from awx.main.models.base import * # noqa +from awx.main.models.mixins import ResourceMixin __all__ = ['Credential'] -class Credential(PasswordFieldsModel, CommonModelNameNotUnique): +class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): ''' A credential contains information about how to talk to a remote resource Usually this is a SSH key location, and possibly an unlock password. @@ -154,12 +155,6 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): default='', help_text=_('Vault password (or "ASK" to prompt the user).'), ) - resource = ImplicitResourceField( - parent_resource=[ - 'user.resource', - 'team.resource' - ] - ) owner_role = ImplicitRoleField( role_name='Credential Owner', parent_role=[ diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index a4c593e5d2..b75e55cfa5 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -18,11 +18,12 @@ from django.utils.timezone import now # AWX from awx.main.constants import CLOUD_PROVIDERS -from awx.main.fields import AutoOneToOneField, ImplicitResourceField, ImplicitRoleField +from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.managers import HostManager from awx.main.models.base import * # noqa from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * # noqa +from awx.main.models.mixins import ResourceMixin from awx.main.utils import ignore_inventory_computed_fields, _inventory_updates __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript'] @@ -30,7 +31,7 @@ __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', ' logger = logging.getLogger('awx.main.models.inventory') -class Inventory(CommonModel): +class Inventory(CommonModel, ResourceMixin): ''' an inventory source contains lists and hosts. ''' @@ -92,9 +93,6 @@ class Inventory(CommonModel): editable=False, help_text=_('Number of external inventory sources in this inventory with failures.'), ) - resource = ImplicitResourceField( - parent_resource='organization.resource' - ) admin_role = ImplicitRoleField( role_name='Inventory Administrator', parent_role='organization.admin_role', @@ -468,7 +466,7 @@ class Host(CommonModelNameNotUnique): # Use .job_events.all() to get events affecting this host. -class Group(CommonModelNameNotUnique): +class Group(CommonModelNameNotUnique, ResourceMixin): ''' A group containing managed hosts. A group or host may belong to multiple groups. @@ -538,9 +536,6 @@ class Group(CommonModelNameNotUnique): editable=False, help_text=_('Inventory source(s) that created or modified this group.'), ) - resource = ImplicitResourceField( - parent_resource='inventory.resource' - ) admin_role = ImplicitRoleField( role_name='Inventory Group Administrator', parent_role='inventory.admin_role', @@ -1096,7 +1091,7 @@ class InventorySourceOptions(BaseModel): return ','.join(choices) -class InventorySource(UnifiedJobTemplate, InventorySourceOptions): +class InventorySource(UnifiedJobTemplate, InventorySourceOptions, ResourceMixin): class Meta: app_label = 'main' @@ -1123,12 +1118,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): update_cache_timeout = models.PositiveIntegerField( default=0, ) - resource = ImplicitResourceField( - parent_resource=[ - 'group.resource', - 'inventory.resource' - ] - ) admin_role = ImplicitRoleField( role_name='Inventory Group Administrator', parent_role=[ diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 019be64abd..9c3866b593 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -26,7 +26,9 @@ from awx.main.utils import decrypt_field, ignore_inventory_computed_fields from awx.main.utils import emit_websocket_notification from awx.main.redact import PlainTextCleaner from awx.main.conf import tower_settings -from awx.main.fields import ImplicitResourceField, ImplicitRoleField +from awx.main.fields import ImplicitRoleField +from awx.main.models.mixins import ResourceMixin + logger = logging.getLogger('awx.main.models.jobs') @@ -150,7 +152,7 @@ class JobOptions(BaseModel): else: return [] -class JobTemplate(UnifiedJobTemplate, JobOptions): +class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): ''' A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. @@ -179,7 +181,6 @@ class JobTemplate(UnifiedJobTemplate, JobOptions): blank=True, default={}, ) - resource = ImplicitResourceField() admin_role = ImplicitRoleField( role_name='Job Template Administrator', parent_role='project.admin_role', diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py new file mode 100644 index 0000000000..32a5d31a57 --- /dev/null +++ b/awx/main/models/mixins.py @@ -0,0 +1,136 @@ +# Django +from django.db import models +from django.db import connection + +# AWX +from awx.main.models.rbac import RolePermission, Role, RoleHierarchy +from awx.main.fields import ImplicitResourceField + +__all__ = 'ResourceMixin' + +class ResourceMixin(models.Model): + + class Meta: + abstract = True + + resource = ImplicitResourceField() + + @classmethod + def accessible_objects(cls, user, permissions): + ''' + Use instead of `MyModel.objects` when you want to only consider + resources that a user has specific permissions for. For example: + + MyModel.accessible_objects(user, {'read': True}).filter(name__istartswith='bar'); + + NOTE: This should only be used for list type things. If you have a + specific resource you want to check permissions on, it is more + performant to resolve the resource in question then call + `myresource.get_permissions(user)`. + ''' + + perm_clause = '' + aggregates = '' + for perm in permissions: + if not perm_clause: + perm_clause = 'WHERE ' + else: + perm_clause += ' AND ' + perm_clause += '"%s" = %d' % (perm, int(permissions[perm])) + aggregates += ', MAX("%s") as "%s"' % (perm, perm) + + return cls.objects.extra( + where=[ + ''' + %(table_name)s.resource_id in ( + SELECT resource_id FROM ( + SELECT %(rbac_permission)s.resource_id %(aggregates)s + FROM %(rbac_role)s_members + LEFT JOIN %(rbac_role_hierachy)s + ON (%(rbac_role_hierachy)s.ancestor_id = %(rbac_role)s_members.role_id) + LEFT JOIN %(rbac_permission)s + ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) + WHERE %(rbac_role)s_members.user_id=%(user_id)d + GROUP BY %(rbac_permission)s.resource_id + ) summarized_permissions + %(perm_clause)s + ) + ''' + % + { + 'table_name': cls._meta.db_table, + 'aggregates': aggregates, + 'user_id': user.id, + 'perm_clause': perm_clause, + 'rbac_role': Role._meta.db_table, + 'rbac_permission': RolePermission._meta.db_table, + 'rbac_role_hierachy': RoleHierarchy._meta.db_table + } + ] + ) + + def get_permissions(self, user): + ''' + Returns a dict (or None) of the permissions a user has for a given + resource. + + Note: Each field in the dict is the `or` of all respective permissions + that have been granted to the roles that are applicable for the given + user. + + In example, if a user has been granted read access through a permission + on one role and write access through a permission on a separate role, + the returned dict will denote that the user has both read and write + access. + ''' + + + with connection.cursor() as cursor: + cursor.execute( + ''' + SELECT + MAX("create") as "create", + MAX("read") as "read", + MAX("write") as "write", + MAX("update") as "update", + MAX("delete") as "delete", + MAX("scm_update") as "scm_update", + MAX("execute") as "execute", + MAX("use") as "use" + + FROM %(rbac_permission)s + LEFT JOIN %(rbac_role_hierachy)s + ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) + LEFT JOIN %(rbac_role)s_members + ON ( + %(rbac_role)s_members.role_id = %(rbac_role_hierachy)s.ancestor_id + AND %(rbac_role)s_members.user_id = %(user_id)d + ) + + WHERE %(rbac_permission)s.resource_id=%(resource_id)s + GROUP BY %(rbac_role)s_members.user_id + ''' + % + { + 'user_id': user.id, + 'resource_id': self.resource.id, + 'rbac_role': Role._meta.db_table, + 'rbac_permission': RolePermission._meta.db_table, + 'rbac_role_hierachy': RoleHierarchy._meta.db_table + } + ) + row = cursor.fetchone() + if row: + return dict(zip([x.name for x in cursor.description], row)) + return None + + def accessible_by(self, user, permissions): + ''' + Returns true if the user has all of the specified permissions + ''' + + perms = self.get_permissions(user) + for k in permissions: + if k not in perms or perms[k] < permissions[k]: + return False + return True diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index fc74aabd58..1caa694dd5 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -16,14 +16,15 @@ from django.utils.timezone import now as tz_now from django.utils.translation import ugettext_lazy as _ # AWX -from awx.main.fields import AutoOneToOneField, ImplicitResourceField, ImplicitRoleField +from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.models.base import * # noqa +from awx.main.models.mixins import ResourceMixin from awx.main.conf import tower_settings __all__ = ['Organization', 'Team', 'Permission', 'Profile', 'AuthToken'] -class Organization(CommonModel): +class Organization(CommonModel, ResourceMixin): ''' An organization is the basic unit of multi-tenancy divisions ''' @@ -51,7 +52,6 @@ class Organization(CommonModel): blank=True, related_name='organizations', ) - resource = ImplicitResourceField() admin_role = ImplicitRoleField( role_name='Organization Administrator', resource_field='resource', @@ -77,7 +77,7 @@ class Organization(CommonModel): super(Organization, self).mark_inactive(save=save) -class Team(CommonModelNameNotUnique): +class Team(CommonModelNameNotUnique, ResourceMixin): ''' A team is a group of users that work on common projects. ''' @@ -104,7 +104,6 @@ class Team(CommonModelNameNotUnique): blank=True, related_name='teams', ) - resource = ImplicitResourceField() admin_role = ImplicitRoleField( role_name='Team Administrator', parent_role='organization.admin_role', diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 172fd09d6f..cb3f143dbd 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -21,8 +21,9 @@ from awx.lib.compat import slugify from awx.main.models.base import * # noqa from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * # noqa +from awx.main.models.mixins import ResourceMixin from awx.main.utils import update_scm_url -from awx.main.fields import ImplicitResourceField, ImplicitRoleField +from awx.main.fields import ImplicitRoleField __all__ = ['Project', 'ProjectUpdate'] @@ -186,7 +187,7 @@ class ProjectOptions(models.Model): return sorted(results, key=lambda x: smart_str(x).lower()) -class Project(UnifiedJobTemplate, ProjectOptions): +class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): ''' A project represents a playbook git repo that can access a set of inventories ''' @@ -214,7 +215,6 @@ class Project(UnifiedJobTemplate, ProjectOptions): default=0, blank=True, ) - resource = ImplicitResourceField() admin_role = ImplicitRoleField( role_name='Project Administrator', parent_role='organization.admin_role', From cf298f6803bbdb84cba689a5e113dd383abcb75e Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 15:13:24 -0500 Subject: [PATCH 007/297] Single permission query optimization --- awx/main/models/mixins.py | 46 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 32a5d31a57..4071e6d3cd 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -29,15 +29,23 @@ class ResourceMixin(models.Model): `myresource.get_permissions(user)`. ''' - perm_clause = '' + aggregate_where_clause = '' aggregates = '' - for perm in permissions: - if not perm_clause: - perm_clause = 'WHERE ' - else: - perm_clause += ' AND ' - perm_clause += '"%s" = %d' % (perm, int(permissions[perm])) - aggregates += ', MAX("%s") as "%s"' % (perm, perm) + group_clause = '' + where_clause = '' + + if len(permissions) > 1: + group_clause = 'GROUP BY %s.resource_id' % RolePermission._meta.db_table + for perm in permissions: + if not aggregate_where_clause: + aggregate_where_clause = 'WHERE ' + else: + aggregate_where_clause += ' AND ' + aggregate_where_clause += '"%s" = %d' % (perm, int(permissions[perm])) + aggregates += ', MAX("%s") as "%s"' % (perm, perm) + if len(permissions) == 1: + perm = list(permissions.keys())[0] + where_clause = 'AND "%s" = %d' % (perm, int(permissions[perm])) return cls.objects.extra( where=[ @@ -51,24 +59,28 @@ class ResourceMixin(models.Model): LEFT JOIN %(rbac_permission)s ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) WHERE %(rbac_role)s_members.user_id=%(user_id)d - GROUP BY %(rbac_permission)s.resource_id + %(where_clause)s + %(group_clause)s ) summarized_permissions - %(perm_clause)s + %(aggregate_where_clause)s ) ''' % { - 'table_name': cls._meta.db_table, - 'aggregates': aggregates, - 'user_id': user.id, - 'perm_clause': perm_clause, - 'rbac_role': Role._meta.db_table, - 'rbac_permission': RolePermission._meta.db_table, - 'rbac_role_hierachy': RoleHierarchy._meta.db_table + 'table_name' : cls._meta.db_table, + 'aggregates' : aggregates, + 'user_id' : user.id, + 'aggregate_where_clause' : aggregate_where_clause, + 'group_clause' : group_clause, + 'where_clause' : where_clause, + 'rbac_role' : Role._meta.db_table, + 'rbac_permission' : RolePermission._meta.db_table, + 'rbac_role_hierachy' : RoleHierarchy._meta.db_table } ] ) + def get_permissions(self, user): ''' Returns a dict (or None) of the permissions a user has for a given From 74163d3711cc6e3158401a9f2d7ca6eadedabb39 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 15:14:09 -0500 Subject: [PATCH 008/297] Added `Role.grant` method for convenient permission granting --- awx/main/models/rbac.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 36db604997..19a926c667 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -59,6 +59,27 @@ class Role(CommonModelNameNotUnique): for child in self.children.all(): child.rebuild_role_hierarchy_cache() + def grant(self, resource, permissions): + # take either the raw Resource or something that includes the ResourceMixin + resource = resource if type(resource) is Resource else resource.resource + + if 'all' in permissions and permissions['all']: + del permissions['all'] + permissions['create'] = True + permissions['read'] = True + permissions['write'] = True + permissions['update'] = True + permissions['delete'] = True + permissions['scm_update'] = True + permissions['use'] = True + permissions['execute'] = True + + permission = RolePermission(role=self, resource=resource) + for k in permissions: + setattr(permission, k, int(permissions[k])) + permission.save() + + m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) @@ -161,4 +182,3 @@ class RolePermission(CreatedModifiedModel): scm_update = models.IntegerField(default = 0) use = models.IntegerField(default = 0) - From 1cd8f6f46a739c16d7cce9ad4d63f24f82b5fb15 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 15:16:28 -0500 Subject: [PATCH 009/297] Moved m2m signal handler out to our common signals.py --- awx/main/models/rbac.py | 8 -------- awx/main/signals.py | 5 +++++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 19a926c667..af54858af0 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -7,7 +7,6 @@ import logging # Django from django.db import models from django.utils.translation import ugettext_lazy as _ -from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed # AWX from awx.main.models.base import * # noqa @@ -17,10 +16,6 @@ __all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy', 'ResourceHiera logger = logging.getLogger('awx.main.models.rbac') - -def rebuild_role_hierarchy_cache(sender, **kwargs): - kwargs['instance'].rebuild_role_hierarchy_cache() - class Role(CommonModelNameNotUnique): ''' Role model @@ -80,9 +75,6 @@ class Role(CommonModelNameNotUnique): permission.save() -m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) - - class RoleHierarchy(CreatedModifiedModel): ''' Stores a flattened relation map of all roles in the system for easy joining diff --git a/awx/main/signals.py b/awx/main/signals.py index 8b0c22ec9d..0b38ecccca 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -115,6 +115,9 @@ def store_initial_active_state(sender, **kwargs): else: instance._saved_active_state = True +def rebuild_role_hierarchy_cache(sender, **kwargs): + kwargs['instance'].rebuild_role_hierarchy_cache() + pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -133,6 +136,8 @@ post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) +m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) + # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. From 4d080497cc2545eab91ecf3ad79307dda8e90ca2 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 15:25:26 -0500 Subject: [PATCH 010/297] Updated inventory role/resource model to better match the spec --- awx/main/models/inventory.py | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b75e55cfa5..313204454a 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -105,6 +105,12 @@ class Inventory(CommonModel, ResourceMixin): resource_field='resource', permissions = { 'read': True } ) + updater_role = ImplicitRoleField( + role_name='Inventory Updater', + ) + executor_role = ImplicitRoleField( + role_name='Inventory Executor', + ) def get_absolute_url(self): return reverse('api:inventory_detail', args=(self.pk,)) @@ -330,7 +336,7 @@ class Inventory(CommonModel, ResourceMixin): return self.groups.exclude(parents__pk__in=group_pks).distinct() -class Host(CommonModelNameNotUnique): +class Host(CommonModelNameNotUnique, ResourceMixin): ''' A managed node ''' @@ -548,6 +554,14 @@ class Group(CommonModelNameNotUnique, ResourceMixin): resource_field='resource', permissions = { 'read': True } ) + updater_role = ImplicitRoleField( + role_name='Inventory Group Updater', + parent_role='inventory.updater_role' + ) + executor_role = ImplicitRoleField( + role_name='Inventory Group Executor', + parent_role='inventory.executor_role' + ) def __unicode__(self): return self.name @@ -1118,24 +1132,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, ResourceMixin) update_cache_timeout = models.PositiveIntegerField( default=0, ) - admin_role = ImplicitRoleField( - role_name='Inventory Group Administrator', - parent_role=[ - 'group.admin_role', - 'inventory.admin_role', - ], - resource_field='resource', - permissions = { 'all': True } - ) - auditor_role = ImplicitRoleField( - role_name='Inventory Group Auditor', - parent_role=[ - 'group.auditor_role', - 'inventory.auditor_role', - ], - resource_field='resource', - permissions = { 'read': True } - ) @classmethod def _get_unified_job_class(cls): From 1035a6737e3f9b331cb346097ebdb58cb5534a87 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 16:37:13 -0500 Subject: [PATCH 011/297] Added singleton role support method and parent_role auto-binder in the ImplicitRoleField Also fixed bug in the single object permission lookup. --- awx/main/fields.py | 10 ++++++++-- awx/main/models/mixins.py | 4 +++- awx/main/models/rbac.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index d6a7dcf4d4..f6afd7581e 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -119,11 +119,17 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): # Add all non-null parent roles as parents if type(self.parent_role) is list: for path in self.parent_role: - parent = resolve_field(instance, path) + if path.startswith("singleton:"): + parent = Role.singleton(path[10:]) + else: + parent = resolve_field(instance, path) if parent: role.parents.add(parent) else: - parent = resolve_field(instance, self.parent_role) + if self.parent_role.startswith("singleton:"): + parent = Role.singleton(self.parent_role[10:]) + else: + parent = resolve_field(instance, self.parent_role) if parent: role.parents.add(parent) setattr(instance, self.field.name, role) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 4071e6d3cd..b1156e4913 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -113,7 +113,7 @@ class ResourceMixin(models.Model): FROM %(rbac_permission)s LEFT JOIN %(rbac_role_hierachy)s ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) - LEFT JOIN %(rbac_role)s_members + INNER JOIN %(rbac_role)s_members ON ( %(rbac_role)s_members.role_id = %(rbac_role_hierachy)s.ancestor_id AND %(rbac_role)s_members.user_id = %(user_id)d @@ -142,6 +142,8 @@ class ResourceMixin(models.Model): ''' perms = self.get_permissions(user) + if not perms: + return False for k in permissions: if k not in perms or perms[k] < permissions[k]: return False diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index af54858af0..37948fe14e 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -26,6 +26,7 @@ class Role(CommonModelNameNotUnique): verbose_name_plural = _('roles') db_table = 'main_rbac_roles' + singleton_name = models.TextField(null=True, default=None, db_index=True, unique=True) parents = models.ManyToManyField('Role', related_name='children') members = models.ManyToManyField('auth.User', related_name='roles') @@ -74,6 +75,16 @@ class Role(CommonModelNameNotUnique): setattr(permission, k, int(permissions[k])) permission.save() + @staticmethod + def singleton(name): + try: + return Role.objects.get(singleton_name=name) + except Role.DoesNotExist: + ret = Role(singleton_name=name) + ret.save() + return ret; + + class RoleHierarchy(CreatedModifiedModel): ''' From c6b2e509fd16e462de4e169c20920d1d44579762 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 29 Jan 2016 18:22:08 -0500 Subject: [PATCH 012/297] Fixed ImplicitRoleField and ImplicitResourceField's from being too lazy This ensures that the role and resource fields get created and bound automatically without having to explicitly access them a first time. --- awx/main/fields.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/awx/main/fields.py b/awx/main/fields.py index f6afd7581e..e5210b423b 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Django +from django.db.models.signals import post_save, post_init from django.db import models from django.db.models.fields.related import SingleRelatedObjectDescriptor from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor @@ -91,7 +92,11 @@ class ImplicitResourceField(models.ForeignKey): def contribute_to_class(self, cls, name): super(ImplicitResourceField, self).contribute_to_class(cls, name) setattr(cls, self.name, ResourceFieldDescriptor(self.parent_resource, self)) + post_save.connect(self._save, cls, True) + def _save(self, instance, *args, **kwargs): + # Ensure that our field gets initialized after our first save + getattr(instance, self.name) class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): @@ -185,4 +190,8 @@ class ImplicitRoleField(models.ForeignKey): self ) ) + post_save.connect(self._save, cls, True) + def _save(self, instance, *args, **kwargs): + # Ensure that our field gets initialized after our first save + getattr(instance, self.name) From 932b6a4c82edcb416fd802795224ee619adc3a62 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 2 Feb 2016 14:47:21 -0500 Subject: [PATCH 013/297] add basic Organization migration --- awx/main/models/organization.py | 26 ++++++++++++----- .../tests/functional/test_rbac_migrations.py | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 awx/main/tests/functional/test_rbac_migrations.py diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 1caa694dd5..d79a6972e2 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -53,12 +53,12 @@ class Organization(CommonModel, ResourceMixin): related_name='organizations', ) admin_role = ImplicitRoleField( - role_name='Organization Administrator', + role_name='Organization Administrator', resource_field='resource', permissions = { 'all': True } ) auditor_role = ImplicitRoleField( - role_name='Organization Auditor', + role_name='Organization Auditor', resource_field='resource', permissions = { 'read': True } ) @@ -76,6 +76,16 @@ class Organization(CommonModel, ResourceMixin): script.save() super(Organization, self).mark_inactive(save=save) + def migrate_to_rbac(self): + migrated_users = [] + for admin in self.admins.all(): + self.admin_role.members.add(admin) + migrated_users.append(admin) + for user in self.users.all(): + self.auditor_role.members.add(user) + migrated_user.append(user) + return migrated_users + class Team(CommonModelNameNotUnique, ResourceMixin): ''' @@ -105,19 +115,19 @@ class Team(CommonModelNameNotUnique, ResourceMixin): related_name='teams', ) admin_role = ImplicitRoleField( - role_name='Team Administrator', + role_name='Team Administrator', parent_role='organization.admin_role', resource_field='resource', permissions = { 'all': True } ) auditor_role = ImplicitRoleField( - role_name='Team Auditor', + role_name='Team Auditor', parent_role='organization.auditor_role', resource_field='resource', permissions = { 'read': True } ) member_role = ImplicitRoleField( - role_name='Team Member', + role_name='Team Member', parent_role='admin_role', ) @@ -210,7 +220,7 @@ class Profile(CreatedModifiedModel): ) """ -Since expiration and session expiration is event driven a token could be +Since expiration and session expiration is event driven a token could be invalidated for both reasons. Further, we only support a single reason for a session token being invalid. For this case, mark the token as expired. @@ -234,7 +244,7 @@ class AuthToken(BaseModel): class Meta: app_label = 'main' - + key = models.CharField(max_length=40, primary_key=True) user = models.ForeignKey('auth.User', related_name='auth_tokens', on_delete=models.CASCADE) @@ -351,7 +361,7 @@ def user_mark_inactive(user, save=True): user.is_active = False if save: user.save() - + User.add_to_class('mark_inactive', user_mark_inactive) diff --git a/awx/main/tests/functional/test_rbac_migrations.py b/awx/main/tests/functional/test_rbac_migrations.py new file mode 100644 index 0000000000..9747f67ee9 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_migrations.py @@ -0,0 +1,29 @@ +import pytest + +from awx.main.models.organization import Organization +from django.contrib.auth.models import User + +def make_user(name, admin=False): + email = '%s@example.org' % name + if admin == True: + return User.objects.create_superuser(name, email, name) + else: + return User.objects.create_user(name, email, name) + +@pytest.fixture +def organization(): + return Organization.objects.create(name="test-org", description="test-org-desc") + +@pytest.mark.django_db +@pytest.mark.parametrize("username,admin", [ + ("admin", True), + ("user", False), +]) +def test_organization_migration(organization, username, admin): + user = make_user(username, admin) + organization.admins.add(user) + + migrated_users = organization.migrate_to_rbac() + assert len(migrated_users) == 1 + assert migrated_users[0] == user + From 896ecab0311bcddfed378cfa48037d2b36ba8dff Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 2 Feb 2016 14:47:46 -0500 Subject: [PATCH 014/297] Added rbac tests and migrations for Organization --- Makefile | 2 +- awx/main/models/mixins.py | 2 +- awx/main/models/organization.py | 2 +- awx/main/tests/functional/conftest.py | 18 ++++++++ .../tests/functional/test_rbac_migrations.py | 46 ++++++++++++++----- pytest.ini | 2 +- 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 awx/main/tests/functional/conftest.py diff --git a/Makefile b/Makefile index c5735982b9..90db6a796c 100644 --- a/Makefile +++ b/Makefile @@ -363,7 +363,7 @@ test_unit: # Run all API unit tests with coverage enabled. test_coverage: - py.test --cov=awx --cov-report=xml --junitxml=./reports/junit.xml awx/main/tests awx/api/tests awx/fact/tests + py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml awx/main/tests awx/api/tests awx/fact/tests # Output test coverage as HTML (into htmlcov directory). coverage_html: diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index b1156e4913..8ce444bbb4 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -133,7 +133,7 @@ class ResourceMixin(models.Model): ) row = cursor.fetchone() if row: - return dict(zip([x.name for x in cursor.description], row)) + return dict(zip([x[0] for x in cursor.description], row)) return None def accessible_by(self, user, permissions): diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index d79a6972e2..37cd56543d 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -83,7 +83,7 @@ class Organization(CommonModel, ResourceMixin): migrated_users.append(admin) for user in self.users.all(): self.auditor_role.members.add(user) - migrated_user.append(user) + migrated_users.append(user) return migrated_users diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py new file mode 100644 index 0000000000..98d31528ce --- /dev/null +++ b/awx/main/tests/functional/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from awx.main.models.organization import Organization + +@pytest.fixture +def organization(): + return Organization.objects.create(name="test-org", description="test-org-desc") + +@pytest.fixture +def permissions(): + return { + 'admin':{'create':True, 'read':True, 'write':True, + 'update':True, 'delete':True, 'scm_update':True, 'execute':True, 'use':True,}, + + 'auditor':{'read':True, 'create':False, 'write':False, + 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':False,}, + } + diff --git a/awx/main/tests/functional/test_rbac_migrations.py b/awx/main/tests/functional/test_rbac_migrations.py index 9747f67ee9..1e4c90e936 100644 --- a/awx/main/tests/functional/test_rbac_migrations.py +++ b/awx/main/tests/functional/test_rbac_migrations.py @@ -1,29 +1,51 @@ import pytest -from awx.main.models.organization import Organization +from awx.main.access import OrganizationAccess from django.contrib.auth.models import User def make_user(name, admin=False): - email = '%s@example.org' % name - if admin == True: - return User.objects.create_superuser(name, email, name) - else: - return User.objects.create_user(name, email, name) - -@pytest.fixture -def organization(): - return Organization.objects.create(name="test-org", description="test-org-desc") + try: + user = User.objects.get(username=name) + except User.DoesNotExist: + user = User(username=name, is_superuser=admin, password=name) + user.save() + return user @pytest.mark.django_db @pytest.mark.parametrize("username,admin", [ ("admin", True), ("user", False), ]) -def test_organization_migration(organization, username, admin): +def test_organization_migration(organization, permissions, username, admin): user = make_user(username, admin) - organization.admins.add(user) + if admin: + organization.admins.add(user) + else: + organization.users.add(user) migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 assert migrated_users[0] == user + if admin: + assert organization.accessible_by(user, permissions['admin']) == True + else: + assert organization.accessible_by(user, permissions['auditor']) == True + +@pytest.mark.django_db +@pytest.mark.parametrize("username,admin", [ + ("admin", True), + ("user-admin", False), + ("user", False) +]) +def test_organization_access(organization, username, admin): + user = make_user(username, admin) + access = OrganizationAccess(user) + if admin: + assert access.can_change(organization, None) == True + elif username == "user-admin": + organization.admins.add(user) + assert access.can_change(organization, None) == True + else: + assert access.can_change(organization, None) == False + diff --git a/pytest.ini b/pytest.ini index a679c1bdc4..90f45f0b2a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ DJANGO_SETTINGS_MODULE = awx.settings.development python_paths = awx/lib/site-packages site_dirs = awx/lib/site-packages python_files = *.py -addopts = --create-db +addopts = --reuse-db From 724b572a3c2cea5171cc65c01be1a5a3b7b8879b Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 4 Feb 2016 15:52:15 -0500 Subject: [PATCH 015/297] test_rbac_migrations -> test_rbac_organization --- .../{test_rbac_migrations.py => test_rbac_organization.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename awx/main/tests/functional/{test_rbac_migrations.py => test_rbac_organization.py} (100%) diff --git a/awx/main/tests/functional/test_rbac_migrations.py b/awx/main/tests/functional/test_rbac_organization.py similarity index 100% rename from awx/main/tests/functional/test_rbac_migrations.py rename to awx/main/tests/functional/test_rbac_organization.py From b903726ddba0637fdd6f2fba32f534f8ffede0c5 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 4 Feb 2016 16:53:06 -0500 Subject: [PATCH 016/297] updated organization rbac tests --- awx/main/tests/functional/conftest.py | 12 ++++ .../functional/test_rbac_organization.py | 68 ++++++++----------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 98d31528ce..22dd64c894 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,6 +1,18 @@ import pytest from awx.main.models.organization import Organization +from django.contrib.auth.models import User + +@pytest.fixture +def user(): + def u(name, is_superuser=False): + try: + user = User.objects.get(username=name) + except User.DoesNotExist: + user = User(username=name, is_superuser=is_superuser, password=name) + user.save() + return user + return u @pytest.fixture def organization(): diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 1e4c90e936..669eb105a3 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -1,51 +1,39 @@ import pytest from awx.main.access import OrganizationAccess -from django.contrib.auth.models import User - -def make_user(name, admin=False): - try: - user = User.objects.get(username=name) - except User.DoesNotExist: - user = User(username=name, is_superuser=admin, password=name) - user.save() - return user @pytest.mark.django_db -@pytest.mark.parametrize("username,admin", [ - ("admin", True), - ("user", False), -]) -def test_organization_migration(organization, permissions, username, admin): - user = make_user(username, admin) - if admin: - organization.admins.add(user) - else: - organization.users.add(user) +def test_organization_migration_admin(organization, permissions, user): + u = user('admin', True) + organization.admins.add(u) migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 - assert migrated_users[0] == user - - if admin: - assert organization.accessible_by(user, permissions['admin']) == True - else: - assert organization.accessible_by(user, permissions['auditor']) == True + assert organization.accessible_by(u, permissions['admin']) == True @pytest.mark.django_db -@pytest.mark.parametrize("username,admin", [ - ("admin", True), - ("user-admin", False), - ("user", False) -]) -def test_organization_access(organization, username, admin): - user = make_user(username, admin) - access = OrganizationAccess(user) - if admin: - assert access.can_change(organization, None) == True - elif username == "user-admin": - organization.admins.add(user) - assert access.can_change(organization, None) == True - else: - assert access.can_change(organization, None) == False +def test_organization_migration_user(organization, permissions, user): + u = user('user', False) + organization.users.add(u) + migrated_users = organization.migrate_to_rbac() + assert len(migrated_users) == 1 + assert organization.accessible_by(u, permissions['auditor']) == True + +@pytest.mark.django_db +def test_organization_access_superuser(organization, user): + access = OrganizationAccess(user('admin', True)) + assert access.can_change(organization, None) == True + +@pytest.mark.django_db +def test_organization_access_admin(organization, user): + u = user('admin', False) + organization.admins.add(u) + + access = OrganizationAccess(u) + assert access.can_change(organization, None) == True + +@pytest.mark.django_db +def test_organization_access_user(organization, user): + access = OrganizationAccess(user('user', False)) + assert access.can_change(organization, None) == False From 58a603bac12a56482f7ee737ef6b190c30766ffd Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 5 Feb 2016 08:47:15 -0500 Subject: [PATCH 017/297] added Credential.migrate_to_rbac and tests --- awx/main/models/credential.py | 20 ++++++--- awx/main/tests/functional/conftest.py | 17 ++++++- .../tests/functional/test_rbac_credential.py | 44 +++++++++++++++++++ 3 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 awx/main/tests/functional/test_rbac_credential.py diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 6e16851145..a7645fdfe8 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -156,19 +156,16 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): help_text=_('Vault password (or "ASK" to prompt the user).'), ) owner_role = ImplicitRoleField( - role_name='Credential Owner', - parent_role=[ - 'user.user_role', - 'team.admin_role' - ], + role_name='Credential Owner', + parent_role='team.admin_role', resource_field='resource', permissions = { 'all': True } ) usage_role = ImplicitRoleField( - role_name='Credential User', + role_name='Credential User', resource_field='resource', parent_role= 'team.member_role', - permissions = { 'usage': True } + permissions = { 'use': True } ) @property @@ -366,6 +363,15 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): update_fields.append('cloud') super(Credential, self).save(*args, **kwargs) + def migrate_to_rbac(self): + if self.user: + self.owner_role.members.add(self.user) + return [self.user] + elif self.team: + self.owner_role.parents.add(self.team.admin_role) + self.usage_role.parents.add(self.team.member_role) + return [self.team] + def validate_ssh_private_key(data): """Validate that the given SSH private key or certificate is, in fact, valid. diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 22dd64c894..d3454a8a4e 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,6 +1,10 @@ import pytest -from awx.main.models.organization import Organization +from awx.main.models.credential import Credential +from awx.main.models.organization import ( + Organization, + Team, +) from django.contrib.auth.models import User @pytest.fixture @@ -14,10 +18,18 @@ def user(): return user return u +@pytest.fixture +def team(organization): + return Team.objects.create(organization=organization, name='test-team') + @pytest.fixture def organization(): return Organization.objects.create(name="test-org", description="test-org-desc") +@pytest.fixture +def credential(): + return Credential.objects.create(kind='aws', name='test-cred') + @pytest.fixture def permissions(): return { @@ -26,5 +38,8 @@ def permissions(): 'auditor':{'read':True, 'create':False, 'write':False, 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':False,}, + + 'usage':{'read':False, 'create':False, 'write':False, + 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, } diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py new file mode 100644 index 0000000000..8868501e6a --- /dev/null +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -0,0 +1,44 @@ +import pytest + +@pytest.mark.django_db +def test_credential_migration_user(credential, user, permissions): + u = user('user', False) + credential.user = u + migrated = credential.migrate_to_rbac() + assert len(migrated) == 1 + assert credential.accessible_by(u, permissions['admin']) + +@pytest.mark.django_db +def test_credential_usage_role(credential, user, permissions): + u = user('user', False) + credential.usage_role.members.add(u) + assert credential.accessible_by(u, permissions['usage']) + +@pytest.mark.django_db +def test_credential_migration_team_member(credential, team, user, permissions): + u = user('user', False) + team.admin_role.members.add(u) + credential.team = team + + # No permissions pre-migration + assert credential.accessible_by(u, permissions['admin']) == False + + migrated = credential.migrate_to_rbac() + # Admin permissions post migration + assert len(migrated) == 1 + assert credential.accessible_by(u, permissions['admin']) + +@pytest.mark.django_db +def test_credential_migration_team_admin(credential, team, user, permissions): + u = user('user', False) + team.member_role.members.add(u) + credential.team = team + + # No permissions pre-migration + assert credential.accessible_by(u, permissions['usage']) == False + + # Usage permissions post migration + migrated = credential.migrate_to_rbac() + assert len(migrated) == 1 + assert credential.accessible_by(u, permissions['usage']) + From 89236a1fe61e4178218c573c229b3b47e13b68be Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 5 Feb 2016 08:50:27 -0500 Subject: [PATCH 018/297] extended test_rbac_organization tests --- awx/main/tests/functional/test_rbac_organization.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 669eb105a3..baefd56acb 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -7,18 +7,22 @@ def test_organization_migration_admin(organization, permissions, user): u = user('admin', True) organization.admins.add(u) + assert organization.accessible_by(u, permissions['admin']) == False + migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 - assert organization.accessible_by(u, permissions['admin']) == True + assert organization.accessible_by(u, permissions['admin']) @pytest.mark.django_db def test_organization_migration_user(organization, permissions, user): u = user('user', False) organization.users.add(u) + assert organization.accessible_by(u, permissions['auditor']) == False + migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 - assert organization.accessible_by(u, permissions['auditor']) == True + assert organization.accessible_by(u, permissions['auditor']) @pytest.mark.django_db def test_organization_access_superuser(organization, user): From 7b3f3675f84ca951f9e568add32b10a3244641a1 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 5 Feb 2016 09:18:52 -0500 Subject: [PATCH 019/297] flake8 fixes --- awx/main/fields.py | 20 +++++++------- awx/main/models/credential.py | 4 +-- awx/main/models/inventory.py | 26 +++++++++---------- awx/main/models/jobs.py | 12 ++++----- awx/main/models/organization.py | 8 +++--- awx/main/models/projects.py | 18 ++++++------- awx/main/models/rbac.py | 6 ++--- awx/main/tests/functional/conftest.py | 2 +- .../tests/functional/test_rbac_credential.py | 4 +-- .../functional/test_rbac_organization.py | 10 +++---- 10 files changed, 55 insertions(+), 55 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index e5210b423b..cb19a4c2c6 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -2,7 +2,7 @@ # All Rights Reserved. # Django -from django.db.models.signals import post_save, post_init +from django.db.models.signals import post_save from django.db import models from django.db.models.fields.related import SingleRelatedObjectDescriptor from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor @@ -66,7 +66,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): # Take first non null parent resource parent = None if type(self.parent_resource) is list: - for path in self.parent_resource: + for path in self.parent_resource: parent = resolve_field(instance, path) if parent: break @@ -123,7 +123,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if self.parent_role: # Add all non-null parent roles as parents if type(self.parent_role) is list: - for path in self.parent_role: + for path in self.parent_role: if path.startswith("singleton:"): parent = Role.singleton(path[10:]) else: @@ -142,7 +142,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if self.resource_field and self.permissions: permissions = RolePermission( - role=role, + role=role, resource=getattr(instance, self.resource_field) ) @@ -180,13 +180,13 @@ class ImplicitRoleField(models.ForeignKey): def contribute_to_class(self, cls, name): super(ImplicitRoleField, self).contribute_to_class(cls, name) - setattr(cls, - self.name, + setattr(cls, + self.name, ImplicitRoleDescriptor( - self.role_name, - self.resource_field, - self.permissions, - self.parent_role, + 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 a7645fdfe8..462cf35249 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -159,13 +159,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): role_name='Credential Owner', parent_role='team.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) usage_role = ImplicitRoleField( role_name='Credential User', resource_field='resource', parent_role= 'team.member_role', - permissions = { 'use': True } + permissions = {'use': True} ) @property diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 313204454a..e33cec1a23 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -94,22 +94,22 @@ class Inventory(CommonModel, ResourceMixin): help_text=_('Number of external inventory sources in this inventory with failures.'), ) admin_role = ImplicitRoleField( - role_name='Inventory Administrator', + role_name='Inventory Administrator', parent_role='organization.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( - role_name='Inventory Auditor', + role_name='Inventory Auditor', parent_role='organization.auditor_role', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) updater_role = ImplicitRoleField( - role_name='Inventory Updater', + role_name='Inventory Updater', ) executor_role = ImplicitRoleField( - role_name='Inventory Executor', + role_name='Inventory Executor', ) def get_absolute_url(self): @@ -543,23 +543,23 @@ class Group(CommonModelNameNotUnique, ResourceMixin): help_text=_('Inventory source(s) that created or modified this group.'), ) admin_role = ImplicitRoleField( - role_name='Inventory Group Administrator', + role_name='Inventory Group Administrator', parent_role='inventory.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( - role_name='Inventory Group Auditor', + role_name='Inventory Group Auditor', parent_role='inventory.auditor_role', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) updater_role = ImplicitRoleField( - role_name='Inventory Group Updater', + role_name='Inventory Group Updater', parent_role='inventory.updater_role' ) executor_role = ImplicitRoleField( - role_name='Inventory Group Executor', + role_name='Inventory Group Executor', parent_role='inventory.executor_role' ) @@ -1186,7 +1186,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, ResourceMixin) return 'never updated' # inherit the child job status else: - return self.last_job.status + return self.last_job.status else: return 'none' diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 9c3866b593..d8f114bc40 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -182,22 +182,22 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): default={}, ) admin_role = ImplicitRoleField( - role_name='Job Template Administrator', + role_name='Job Template Administrator', parent_role='project.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( - role_name='Job Template Auditor', + role_name='Job Template Auditor', parent_role='project.auditor_role', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) executor_role = ImplicitRoleField( - role_name='Job Template Executor', + role_name='Job Template Executor', parent_role='project.auditor_role', resource_field='resource', - permissions = { 'execute': True } + permissions = {'execute': True} ) @classmethod diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 37cd56543d..ffb8d40e24 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -55,12 +55,12 @@ class Organization(CommonModel, ResourceMixin): admin_role = ImplicitRoleField( role_name='Organization Administrator', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Organization Auditor', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) @@ -118,13 +118,13 @@ class Team(CommonModelNameNotUnique, ResourceMixin): role_name='Team Administrator', parent_role='organization.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Team Auditor', parent_role='organization.auditor_role', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Team Member', diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index cb3f143dbd..1da3d51961 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -216,28 +216,28 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): blank=True, ) admin_role = ImplicitRoleField( - role_name='Project Administrator', + role_name='Project Administrator', parent_role='organization.admin_role', resource_field='resource', - permissions = { 'all': True } + permissions = {'all': True} ) auditor_role = ImplicitRoleField( - role_name='Project Auditor', + role_name='Project Auditor', parent_role='organization.auditor_role', resource_field='resource', - permissions = { 'read': True } + permissions = {'read': True} ) member_role = ImplicitRoleField( - role_name='Project Member', + role_name='Project Member', parent_role='admin', resource_field='resource', - permissions = { 'usage': True } + permissions = {'usage': True} ) scm_update_role = ImplicitRoleField( - role_name='Project Updater', + role_name='Project Updater', parent_role='admin', resource_field='resource', - permissions = { 'scm_update': True } + permissions = {'scm_update': True} ) @classmethod @@ -333,7 +333,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): if (self.last_job_run + datetime.timedelta(seconds=self.scm_update_cache_timeout)) > now(): return True return False - + @property def needs_update_on_launch(self): if self.active and self.scm_type and self.scm_update_on_launch: diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 37948fe14e..d8cdaecfe2 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -18,7 +18,7 @@ logger = logging.getLogger('awx.main.models.rbac') class Role(CommonModelNameNotUnique): ''' - Role model + Role model ''' class Meta: @@ -82,7 +82,7 @@ class Role(CommonModelNameNotUnique): except Role.DoesNotExist: ret = Role(singleton_name=name) ret.save() - return ret; + return ret @@ -102,7 +102,7 @@ class RoleHierarchy(CreatedModifiedModel): class Resource(CommonModelNameNotUnique): ''' - Role model + Role model ''' class Meta: diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index d3454a8a4e..c565cebac5 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -40,6 +40,6 @@ def permissions(): 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':False,}, 'usage':{'read':False, 'create':False, 'write':False, - 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, + 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, } diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 8868501e6a..173467f258 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -21,7 +21,7 @@ def test_credential_migration_team_member(credential, team, user, permissions): credential.team = team # No permissions pre-migration - assert credential.accessible_by(u, permissions['admin']) == False + assert not credential.accessible_by(u, permissions['admin']) migrated = credential.migrate_to_rbac() # Admin permissions post migration @@ -35,7 +35,7 @@ def test_credential_migration_team_admin(credential, team, user, permissions): credential.team = team # No permissions pre-migration - assert credential.accessible_by(u, permissions['usage']) == False + assert not credential.accessible_by(u, permissions['usage']) # Usage permissions post migration migrated = credential.migrate_to_rbac() diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index baefd56acb..1eadd5a866 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -7,7 +7,7 @@ def test_organization_migration_admin(organization, permissions, user): u = user('admin', True) organization.admins.add(u) - assert organization.accessible_by(u, permissions['admin']) == False + assert not organization.accessible_by(u, permissions['admin']) migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 @@ -18,7 +18,7 @@ def test_organization_migration_user(organization, permissions, user): u = user('user', False) organization.users.add(u) - assert organization.accessible_by(u, permissions['auditor']) == False + assert not organization.accessible_by(u, permissions['auditor']) migrated_users = organization.migrate_to_rbac() assert len(migrated_users) == 1 @@ -27,7 +27,7 @@ def test_organization_migration_user(organization, permissions, user): @pytest.mark.django_db def test_organization_access_superuser(organization, user): access = OrganizationAccess(user('admin', True)) - assert access.can_change(organization, None) == True + assert access.can_change(organization, None) @pytest.mark.django_db def test_organization_access_admin(organization, user): @@ -35,9 +35,9 @@ def test_organization_access_admin(organization, user): organization.admins.add(u) access = OrganizationAccess(u) - assert access.can_change(organization, None) == True + assert access.can_change(organization, None) @pytest.mark.django_db def test_organization_access_user(organization, user): access = OrganizationAccess(user('user', False)) - assert access.can_change(organization, None) == False + assert not access.can_change(organization, None) From 5ed766ed3582c38dcb1cdd191815e5961212b81b Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 5 Feb 2016 09:57:15 -0500 Subject: [PATCH 020/297] Added Team.migrate_to_rbac and tests --- awx/main/models/organization.py | 9 +++++++++ awx/main/tests/functional/test_rbac_team.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 awx/main/tests/functional/test_rbac_team.py diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index ffb8d40e24..306a4ff42b 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -129,6 +129,8 @@ class Team(CommonModelNameNotUnique, ResourceMixin): member_role = ImplicitRoleField( role_name='Team Member', parent_role='admin_role', + resource_field='resource', + permissions = {'read':True}, ) def get_absolute_url(self): @@ -142,6 +144,13 @@ class Team(CommonModelNameNotUnique, ResourceMixin): cred.mark_inactive() super(Team, self).mark_inactive(save=save) + def migrate_to_rbac(self): + migrated = [] + for user in self.users.all(): + self.member_role.members.add(user) + migrated.append(user) + return migrated + class Permission(CommonModelNameNotUnique): ''' A permission allows a user, project, or team to be able to use an inventory source. diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py new file mode 100644 index 0000000000..42356783f3 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_team.py @@ -0,0 +1,13 @@ +import pytest + +@pytest.mark.django_db +def test_team_migration_user(team, user, permissions): + u = user('user', False) + team.users.add(u) + + assert not team.accessible_by(u, permissions['auditor']) + + migrated = team.migrate_to_rbac() + assert len(migrated) == 1 + assert team.accessible_by(u, permissions['auditor']) + From 4540eb0079c4c2821700ca4b9a5b19cf849eb31b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 16:46:00 -0500 Subject: [PATCH 021/297] Updated role hierarchy cache rebuilder to handle adds to .children as well as .parents --- awx/main/signals.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/awx/main/signals.py b/awx/main/signals.py index 0b38ecccca..d5f07170ab 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -115,8 +115,12 @@ def store_initial_active_state(sender, **kwargs): else: instance._saved_active_state = True -def rebuild_role_hierarchy_cache(sender, **kwargs): - kwargs['instance'].rebuild_role_hierarchy_cache() +def rebuild_role_hierarchy_cache(sender, reverse, model, pk_set, **kwargs): + if reverse: + for id in pk_set: + model.objects.get(id=id).rebuild_role_hierarchy_cache() + else: + kwargs['instance'].rebuild_role_hierarchy_cache() pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) From 332b8b3b490df70fcfa62e265fa75032794bf084 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 16:58:16 -0500 Subject: [PATCH 022/297] Added Role.is_ancestor_of predicate --- awx/main/models/rbac.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index d8cdaecfe2..9459bc78d6 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -84,6 +84,9 @@ class Role(CommonModelNameNotUnique): ret.save() return ret + def is_ancestor_of(self, role): + return RoleHierarchy.objects.filter(role_id=role.id, ancestor_id=self.id).count() > 0 + class RoleHierarchy(CreatedModifiedModel): From 9f33835582832f0392523c7531f47c51b8f5a39b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 16:58:41 -0500 Subject: [PATCH 023/297] Added RBAC migration code --- awx/main/models/inventory.py | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index e33cec1a23..a31dd76bb9 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -21,6 +21,7 @@ from awx.main.constants import CLOUD_PROVIDERS from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.managers import HostManager from awx.main.models.base import * # noqa +from awx.main.models.organization import Permission # for rbac migration from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * # noqa from awx.main.models.mixins import ResourceMixin @@ -112,6 +113,48 @@ class Inventory(CommonModel, ResourceMixin): role_name='Inventory Executor', ) + def migrate_to_rbac(self): + migrated_users = [] + migrated_teams = [] + + for perm in Permission.objects.filter(inventory=self): + role = None + execrole = None + if perm.permission_type == 'admin': + role = self.admin_role + pass + elif perm.permission_type == 'read': + role = self.auditor_role + pass + elif perm.permission_type == 'write': + role = self.updater_role + pass + else: + raise Exception('Unhandled permission type for inventory: %s' % perm.permission_type) + if perm.run_ad_hoc_commands: + execrole = self.executor_role + + if perm.team: + if role: + perm.team.member_role.children.add(role) + if execrole: + perm.team.member_role.children.add(execrole) + + migrated_teams.append(perm.team) + + if perm.user: + if role: + role.members.add(perm.user) + if execrole: + execrole.members.add(perm.user) + migrated_users.append(perm.user) + + return { + 'migrated_users': migrated_users, + 'migrated_teams': migrated_teams, + } + + def get_absolute_url(self): return reverse('api:inventory_detail', args=(self.pk,)) From d5740408e9e616323668f535e59cd84daa8b13c1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 16:59:22 -0500 Subject: [PATCH 024/297] Addd inventory fixture --- awx/main/tests/functional/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index c565cebac5..7d0a770b27 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,8 +1,10 @@ import pytest from awx.main.models.credential import Credential +from awx.main.models.inventory import Inventory from awx.main.models.organization import ( Organization, + Permission, Team, ) from django.contrib.auth.models import User @@ -30,6 +32,10 @@ def organization(): def credential(): return Credential.objects.create(kind='aws', name='test-cred') +@pytest.fixture +def inventory(organization): + return Inventory.objects.create(name="test-inventory", organization=organization) + @pytest.fixture def permissions(): return { From 619e5797d4ab0c13cd62f1f30ed0d75cf0118b8f Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 16:59:38 -0500 Subject: [PATCH 025/297] RBAC inventory migration tests --- .../tests/functional/test_rbac_inventory.py | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 awx/main/tests/functional/test_rbac_inventory.py diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py new file mode 100644 index 0000000000..0d9c71d156 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -0,0 +1,182 @@ +import pytest + +from awx.main.access import OrganizationAccess +from awx.main.models import ( + Inventory, + Permission, + PERM_INVENTORY_ADMIN, + PERM_INVENTORY_READ, + PERM_INVENTORY_WRITE, + PERM_INVENTORY_DEPLOY, + PERM_INVENTORY_CHECK, + PERM_INVENTORY_SCAN, +) + +@pytest.mark.django_db +def test_inventory_admin_user(inventory, permissions, user): + u = user('admin', False) + perm = Permission(user=u, inventory=inventory, permission_type='admin') + perm.save() + + assert inventory.accessible_by(u, permissions['admin']) == False + + migrations = inventory.migrate_to_rbac() + + assert len(migrations['migrated_users']) == 1 + assert len(migrations['migrated_teams']) == 0 + assert inventory.accessible_by(u, permissions['admin']) + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + +@pytest.mark.django_db +def test_inventory_auditor_user(inventory, permissions, user): + u = user('auditor', False) + perm = Permission(user=u, inventory=inventory, permission_type='read') + perm.save() + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + migrations = inventory.migrate_to_rbac() + + assert len(migrations['migrated_users']) == 1 + assert len(migrations['migrated_teams']) == 0 + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == True + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + +@pytest.mark.django_db +def test_inventory_updater_user(inventory, permissions, user): + u = user('updater', False) + perm = Permission(user=u, inventory=inventory, permission_type='write') + perm.save() + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + migrations = inventory.migrate_to_rbac() + + assert len(migrations['migrated_users']) == 1 + assert len(migrations['migrated_teams']) == 0 + assert inventory.accessible_by(u, permissions['admin']) == False + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert inventory.updater_role.members.filter(id=u.id).exists() + +@pytest.mark.django_db +def test_inventory_executor_user(inventory, permissions, user): + u = user('executor', False) + perm = Permission(user=u, inventory=inventory, permission_type='read', run_ad_hoc_commands=True) + perm.save() + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + migrations = inventory.migrate_to_rbac() + + assert len(migrations['migrated_users']) == 1 + assert len(migrations['migrated_teams']) == 0 + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == True + assert inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + + + +@pytest.mark.django_db +def test_inventory_admin_team(inventory, permissions, user, team): + u = user('admin', False) + perm = Permission(team=team, inventory=inventory, permission_type='admin') + perm.save() + team.users.add(u) + + assert inventory.accessible_by(u, permissions['admin']) == False + + team_migrations = team.migrate_to_rbac() + migrations = inventory.migrate_to_rbac() + + assert len(team_migrations) == 1 + assert team.member_role.members.count() == 1 + assert len(migrations['migrated_users']) == 0 + assert len(migrations['migrated_teams']) == 1 + assert not inventory.admin_role.members.filter(id=u.id).exists() + assert not inventory.auditor_role.members.filter(id=u.id).exists() + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.accessible_by(u, permissions['auditor']) + assert inventory.accessible_by(u, permissions['admin']) + + +@pytest.mark.django_db +def test_inventory_auditor(inventory, permissions, user, team): + u = user('auditor', False) + perm = Permission(team=team, inventory=inventory, permission_type='read') + perm.save() + team.users.add(u) + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + team_migrations = team.migrate_to_rbac() + migrations = inventory.migrate_to_rbac() + + assert len(team_migrations) == 1 + assert team.member_role.members.count() == 1 + assert len(migrations['migrated_users']) == 0 + assert len(migrations['migrated_teams']) == 1 + assert not inventory.admin_role.members.filter(id=u.id).exists() + assert not inventory.auditor_role.members.filter(id=u.id).exists() + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.accessible_by(u, permissions['auditor']) + assert not inventory.accessible_by(u, permissions['admin']) + +@pytest.mark.django_db +def test_inventory_updater(inventory, permissions, user, team): + u = user('updater', False) + perm = Permission(team=team, inventory=inventory, permission_type='write') + perm.save() + team.users.add(u) + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + team_migrations = team.migrate_to_rbac() + migrations = inventory.migrate_to_rbac() + + assert len(team_migrations) == 1 + assert team.member_role.members.count() == 1 + assert len(migrations['migrated_users']) == 0 + assert len(migrations['migrated_teams']) == 1 + assert not inventory.admin_role.members.filter(id=u.id).exists() + assert not inventory.auditor_role.members.filter(id=u.id).exists() + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + assert team.member_role.is_ancestor_of(inventory.updater_role) + assert not team.member_role.is_ancestor_of(inventory.executor_role) + + +@pytest.mark.django_db +def test_inventory_executor(inventory, permissions, user, team): + u = user('executor', False) + perm = Permission(team=team, inventory=inventory, permission_type='read', run_ad_hoc_commands=True) + perm.save() + team.users.add(u) + + assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['auditor']) == False + + team_migrations = team.migrate_to_rbac() + migrations = inventory.migrate_to_rbac() + + assert len(team_migrations) == 1 + assert team.member_role.members.count() == 1 + assert len(migrations['migrated_users']) == 0 + assert len(migrations['migrated_teams']) == 1 + assert not inventory.admin_role.members.filter(id=u.id).exists() + assert not inventory.auditor_role.members.filter(id=u.id).exists() + assert not inventory.executor_role.members.filter(id=u.id).exists() + assert not inventory.updater_role.members.filter(id=u.id).exists() + assert not team.member_role.is_ancestor_of(inventory.updater_role) + assert team.member_role.is_ancestor_of(inventory.executor_role) + From fe29486d7b2e71244c2611b3cf35cc1f92daa331 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 17:01:19 -0500 Subject: [PATCH 026/297] Removed unnecessary save() --- awx/main/fields.py | 1 - 1 file changed, 1 deletion(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index cb19a4c2c6..b86fef5095 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -119,7 +119,6 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): raise FieldError('Implicit role missing `role_name`') role = Role._default_manager.create(name=self.role_name) - role.save() if self.parent_role: # Add all non-null parent roles as parents if type(self.parent_role) is list: From 70229076d2213774a55e7ce8d78cc28dbb3ea589 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 5 Feb 2016 17:03:59 -0500 Subject: [PATCH 027/297] Removed unnecessary ResourceHierarchy model --- awx/main/models/rbac.py | 44 +---------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 9459bc78d6..75ff67cb96 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.models.base import * # noqa -__all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy', 'ResourceHierarchy'] +__all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy'] logger = logging.getLogger('awx.main.models.rbac') @@ -115,47 +115,6 @@ class Resource(CommonModelNameNotUnique): parent = models.ForeignKey('Resource', related_name='children', null=True, default=None) - def save(self, *args, **kwargs): - super(Resource, self).save(*args, **kwargs) - self.rebuild_resource_hierarchy_cache() - - def rebuild_resource_hierarchy_cache(self): - 'Rebuilds the associated entries in the ResourceHierarchy model' - - # Compute what our hierarchy should be. (Note: this depends on our - # parent's cached hierarchy being correct) - actual_ancestors = set() - if self.parent: - actual_ancestors = set([r.ancestor.id for r in ResourceHierarchy.objects.filter(resource__id=self.parent.id)]) - actual_ancestors.add(self.id) - - # Compute what we have stored - stored_ancestors = set([r.ancestor.id for r in ResourceHierarchy.objects.filter(resource__id=self.id)]) - - # If it differs, update, and then update all of our children - if actual_ancestors != stored_ancestors: - ResourceHierarchy.objects.filter(resource__id=self.id).delete() - for id in actual_ancestors: - rh = ResourceHierarchy(resource=self, ancestor=Resource.objects.get(id=id)) - rh.save() - for child in self.children.all(): - child.rebuild_resource_hierarchy_cache() - - - -class ResourceHierarchy(CreatedModifiedModel): - ''' - Stores a flattened relation map of all resources in the system for easy joining - ''' - - class Meta: - app_label = 'main' - verbose_name_plural = _('resource_ancestors') - db_table = 'main_rbac_resource_hierarchy' - - resource = models.ForeignKey('Resource', related_name='+', on_delete=models.CASCADE) - ancestor = models.ForeignKey('Resource', related_name='+', on_delete=models.CASCADE) - class RolePermission(CreatedModifiedModel): ''' @@ -187,4 +146,3 @@ class RolePermission(CreatedModifiedModel): execute = models.IntegerField(default = 0) scm_update = models.IntegerField(default = 0) use = models.IntegerField(default = 0) - From 9ddabeff83bbb061f2ab0341bc071c00f6e5051d Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 10:24:21 -0500 Subject: [PATCH 028/297] flake8 fixups --- awx/main/tests/functional/conftest.py | 1 - .../tests/functional/test_rbac_inventory.py | 100 ++++++++---------- 2 files changed, 45 insertions(+), 56 deletions(-) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 7d0a770b27..31e6eebf6c 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -4,7 +4,6 @@ from awx.main.models.credential import Credential from awx.main.models.inventory import Inventory from awx.main.models.organization import ( Organization, - Permission, Team, ) from django.contrib.auth.models import User diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 0d9c71d156..478de37d18 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -1,16 +1,6 @@ import pytest -from awx.main.access import OrganizationAccess -from awx.main.models import ( - Inventory, - Permission, - PERM_INVENTORY_ADMIN, - PERM_INVENTORY_READ, - PERM_INVENTORY_WRITE, - PERM_INVENTORY_DEPLOY, - PERM_INVENTORY_CHECK, - PERM_INVENTORY_SCAN, -) +from awx.main.models import Permission @pytest.mark.django_db def test_inventory_admin_user(inventory, permissions, user): @@ -18,15 +8,15 @@ def test_inventory_admin_user(inventory, permissions, user): perm = Permission(user=u, inventory=inventory, permission_type='admin') perm.save() - assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['admin']) is False migrations = inventory.migrate_to_rbac() assert len(migrations['migrated_users']) == 1 assert len(migrations['migrated_teams']) == 0 assert inventory.accessible_by(u, permissions['admin']) - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False @pytest.mark.django_db def test_inventory_auditor_user(inventory, permissions, user): @@ -34,17 +24,17 @@ def test_inventory_auditor_user(inventory, permissions, user): perm = Permission(user=u, inventory=inventory, permission_type='read') perm.save() - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False migrations = inventory.migrate_to_rbac() assert len(migrations['migrated_users']) == 1 assert len(migrations['migrated_teams']) == 0 - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == True - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is True + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False @pytest.mark.django_db def test_inventory_updater_user(inventory, permissions, user): @@ -52,15 +42,15 @@ def test_inventory_updater_user(inventory, permissions, user): perm = Permission(user=u, inventory=inventory, permission_type='write') perm.save() - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False migrations = inventory.migrate_to_rbac() assert len(migrations['migrated_users']) == 1 assert len(migrations['migrated_teams']) == 0 - assert inventory.accessible_by(u, permissions['admin']) == False - assert not inventory.executor_role.members.filter(id=u.id).exists() + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.executor_role.members.filter(id=u.id).exists() is False assert inventory.updater_role.members.filter(id=u.id).exists() @pytest.mark.django_db @@ -69,17 +59,17 @@ def test_inventory_executor_user(inventory, permissions, user): perm = Permission(user=u, inventory=inventory, permission_type='read', run_ad_hoc_commands=True) perm.save() - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False migrations = inventory.migrate_to_rbac() assert len(migrations['migrated_users']) == 1 assert len(migrations['migrated_teams']) == 0 - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == True + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is True assert inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.updater_role.members.filter(id=u.id).exists() is False @@ -90,7 +80,7 @@ def test_inventory_admin_team(inventory, permissions, user, team): perm.save() team.users.add(u) - assert inventory.accessible_by(u, permissions['admin']) == False + assert inventory.accessible_by(u, permissions['admin']) is False team_migrations = team.migrate_to_rbac() migrations = inventory.migrate_to_rbac() @@ -99,10 +89,10 @@ def test_inventory_admin_team(inventory, permissions, user, team): assert team.member_role.members.count() == 1 assert len(migrations['migrated_users']) == 0 assert len(migrations['migrated_teams']) == 1 - assert not inventory.admin_role.members.filter(id=u.id).exists() - assert not inventory.auditor_role.members.filter(id=u.id).exists() - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.admin_role.members.filter(id=u.id).exists() is False + assert inventory.auditor_role.members.filter(id=u.id).exists() is False + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False assert inventory.accessible_by(u, permissions['auditor']) assert inventory.accessible_by(u, permissions['admin']) @@ -114,8 +104,8 @@ def test_inventory_auditor(inventory, permissions, user, team): perm.save() team.users.add(u) - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = team.migrate_to_rbac() migrations = inventory.migrate_to_rbac() @@ -124,12 +114,12 @@ def test_inventory_auditor(inventory, permissions, user, team): assert team.member_role.members.count() == 1 assert len(migrations['migrated_users']) == 0 assert len(migrations['migrated_teams']) == 1 - assert not inventory.admin_role.members.filter(id=u.id).exists() - assert not inventory.auditor_role.members.filter(id=u.id).exists() - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.admin_role.members.filter(id=u.id).exists() is False + assert inventory.auditor_role.members.filter(id=u.id).exists() is False + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False assert inventory.accessible_by(u, permissions['auditor']) - assert not inventory.accessible_by(u, permissions['admin']) + assert inventory.accessible_by(u, permissions['admin']) is False @pytest.mark.django_db def test_inventory_updater(inventory, permissions, user, team): @@ -138,8 +128,8 @@ def test_inventory_updater(inventory, permissions, user, team): perm.save() team.users.add(u) - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = team.migrate_to_rbac() migrations = inventory.migrate_to_rbac() @@ -148,12 +138,12 @@ def test_inventory_updater(inventory, permissions, user, team): assert team.member_role.members.count() == 1 assert len(migrations['migrated_users']) == 0 assert len(migrations['migrated_teams']) == 1 - assert not inventory.admin_role.members.filter(id=u.id).exists() - assert not inventory.auditor_role.members.filter(id=u.id).exists() - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() + assert inventory.admin_role.members.filter(id=u.id).exists() is False + assert inventory.auditor_role.members.filter(id=u.id).exists() is False + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False assert team.member_role.is_ancestor_of(inventory.updater_role) - assert not team.member_role.is_ancestor_of(inventory.executor_role) + assert team.member_role.is_ancestor_of(inventory.executor_role) is False @pytest.mark.django_db @@ -163,8 +153,8 @@ def test_inventory_executor(inventory, permissions, user, team): perm.save() team.users.add(u) - assert inventory.accessible_by(u, permissions['admin']) == False - assert inventory.accessible_by(u, permissions['auditor']) == False + assert inventory.accessible_by(u, permissions['admin']) is False + assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = team.migrate_to_rbac() migrations = inventory.migrate_to_rbac() @@ -173,10 +163,10 @@ def test_inventory_executor(inventory, permissions, user, team): assert team.member_role.members.count() == 1 assert len(migrations['migrated_users']) == 0 assert len(migrations['migrated_teams']) == 1 - assert not inventory.admin_role.members.filter(id=u.id).exists() - assert not inventory.auditor_role.members.filter(id=u.id).exists() - assert not inventory.executor_role.members.filter(id=u.id).exists() - assert not inventory.updater_role.members.filter(id=u.id).exists() - assert not team.member_role.is_ancestor_of(inventory.updater_role) + assert inventory.admin_role.members.filter(id=u.id).exists() is False + assert inventory.auditor_role.members.filter(id=u.id).exists() is False + assert inventory.executor_role.members.filter(id=u.id).exists() is False + assert inventory.updater_role.members.filter(id=u.id).exists() is False + assert team.member_role.is_ancestor_of(inventory.updater_role) is False assert team.member_role.is_ancestor_of(inventory.executor_role) From b8a7ad17ea7cd706f322d567c2e6ef82f95c1e75 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 15:33:59 -0500 Subject: [PATCH 029/297] Added initial rbac migrations --- awx/main/migrations/0003_rbac_changes.py | 256 +++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 awx/main/migrations/0003_rbac_changes.py diff --git a/awx/main/migrations/0003_rbac_changes.py b/awx/main/migrations/0003_rbac_changes.py new file mode 100644 index 0000000000..f26aee850d --- /dev/null +++ b/awx/main/migrations/0003_rbac_changes.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import taggit.managers +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0002_v300_changes'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('active', models.BooleanField(default=True, editable=False)), + ('name', models.CharField(max_length=512)), + ('created_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('parent', models.ForeignKey(related_name='children', default=None, to='main.Resource', null=True)), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ], + options={ + 'db_table': 'main_rbac_resources', + 'verbose_name_plural': 'resources', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('active', models.BooleanField(default=True, editable=False)), + ('name', models.CharField(max_length=512)), + ('singleton_name', models.TextField(default=None, unique=True, null=True, db_index=True)), + ('created_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('members', models.ManyToManyField(related_name='roles', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('parents', models.ManyToManyField(related_name='children', to='main.Role')), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ], + options={ + 'db_table': 'main_rbac_roles', + 'verbose_name_plural': 'roles', + }, + ), + migrations.CreateModel( + name='RoleHierarchy', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('ancestor', models.ForeignKey(related_name='+', to='main.Role')), + ('role', models.ForeignKey(related_name='+', to='main.Role')), + ], + options={ + 'db_table': 'main_rbac_role_hierarchy', + 'verbose_name_plural': 'role_ancestors', + }, + ), + migrations.CreateModel( + name='RolePermission', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('create', models.IntegerField(default=0)), + ('read', models.IntegerField(default=0)), + ('write', models.IntegerField(default=0)), + ('update', models.IntegerField(default=0)), + ('delete', models.IntegerField(default=0)), + ('execute', models.IntegerField(default=0)), + ('scm_update', models.IntegerField(default=0)), + ('use', models.IntegerField(default=0)), + ('resource', models.ForeignKey(related_name='permissions', to='main.Resource')), + ('role', models.ForeignKey(related_name='permissions', to='main.Role')), + ], + options={ + 'db_table': 'main_rbac_permissions', + 'verbose_name_plural': 'permissions', + }, + ), + migrations.AddField( + model_name='project', + name='organization', + field=models.ForeignKey(related_name='project_list', on_delete=django.db.models.deletion.SET_NULL, to='main.Organization', null=True), + ), + migrations.AddField( + model_name='credential', + name='owner_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='credential', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='credential', + name='usage_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='executor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='group', + name='updater_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='host', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='executor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='inventory', + name='updater_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='inventorysource', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='executor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='jobtemplate', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='organization', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + migrations.AddField( + model_name='project', + name='scm_update_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='auditor_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), + migrations.AddField( + model_name='team', + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + ), + ] From 1ed18e4561d7fa2aee4a93ee73720ea62e3ef068 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 15:35:39 -0500 Subject: [PATCH 030/297] convert Organization to django migration --- awx/main/migrations/0004_rbac_migrations.py | 16 ++++++++++++++++ awx/main/migrations/_rbac.py | 17 +++++++++++++++++ awx/main/models/organization.py | 10 ---------- .../tests/functional/test_rbac_organization.py | 13 +++++++++---- 4 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 awx/main/migrations/0004_rbac_migrations.py create mode 100644 awx/main/migrations/_rbac.py diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0004_rbac_migrations.py new file mode 100644 index 0000000000..e6c221272d --- /dev/null +++ b/awx/main/migrations/0004_rbac_migrations.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from awx.main.migrations import _rbac as rbac +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0003_rbac_changes'), + ] + + operations = [ + migrations.RunPython(rbac.migrate_organization, rbac.unmigrate_organization), + ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py new file mode 100644 index 0000000000..3c2a176b69 --- /dev/null +++ b/awx/main/migrations/_rbac.py @@ -0,0 +1,17 @@ +from collections import defaultdict + +def migrate_organization(apps, schema_editor): + migrations = defaultdict(list) + organization = apps.get_model('main', "Organization") + for org in organization.objects.all(): + for admin in org.admins.all(): + org.admin_role.members.add(admin) + migrations[org.name].append(admin) + for user in org.users.all(): + org.auditor_role.members.add(user) + migrations[org.name].append(user) + return migrations + + +def unmigrate_organization(apps, schema_editor): + pass diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 306a4ff42b..62636e42e6 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -76,16 +76,6 @@ class Organization(CommonModel, ResourceMixin): script.save() super(Organization, self).mark_inactive(save=save) - def migrate_to_rbac(self): - migrated_users = [] - for admin in self.admins.all(): - self.admin_role.members.add(admin) - migrated_users.append(admin) - for user in self.users.all(): - self.auditor_role.members.add(user) - migrated_users.append(user) - return migrated_users - class Team(CommonModelNameNotUnique, ResourceMixin): ''' diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 1eadd5a866..67422208aa 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -1,6 +1,9 @@ import pytest +from awx.main.migrations import _rbac as rbac from awx.main.access import OrganizationAccess +from django.apps import apps + @pytest.mark.django_db def test_organization_migration_admin(organization, permissions, user): @@ -9,8 +12,9 @@ def test_organization_migration_admin(organization, permissions, user): assert not organization.accessible_by(u, permissions['admin']) - migrated_users = organization.migrate_to_rbac() - assert len(migrated_users) == 1 + migrations = rbac.migrate_organization(apps, None) + + assert len(migrations) == 1 assert organization.accessible_by(u, permissions['admin']) @pytest.mark.django_db @@ -20,8 +24,9 @@ def test_organization_migration_user(organization, permissions, user): assert not organization.accessible_by(u, permissions['auditor']) - migrated_users = organization.migrate_to_rbac() - assert len(migrated_users) == 1 + migrations = rbac.migrate_organization(apps, None) + + assert len(migrations) == 1 assert organization.accessible_by(u, permissions['auditor']) @pytest.mark.django_db From 8cf0ba0da71d0ddec06bb5a1ee601fb2e6b019bf Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 15:54:11 -0500 Subject: [PATCH 031/297] convert Credential to django migration --- awx/main/migrations/0004_rbac_migrations.py | 3 ++- awx/main/migrations/_rbac.py | 14 ++++++++++++-- awx/main/models/credential.py | 8 -------- awx/main/tests/functional/test_rbac_credential.py | 15 ++++++++++++--- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0004_rbac_migrations.py index e6c221272d..5d02d6bd00 100644 --- a/awx/main/migrations/0004_rbac_migrations.py +++ b/awx/main/migrations/0004_rbac_migrations.py @@ -12,5 +12,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(rbac.migrate_organization, rbac.unmigrate_organization), + migrations.RunPython(rbac.migrate_organization), + migrations.RunPython(rbac.migrate_credential), ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 3c2a176b69..b0403b1a0d 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -13,5 +13,15 @@ def migrate_organization(apps, schema_editor): return migrations -def unmigrate_organization(apps, schema_editor): - pass +def migrate_credential(apps, schema_editor): + migrations = defaultdict(list) + credential = apps.get_model('main', "Credential") + for cred in credential.objects.all(): + if cred.user: + cred.owner_role.members.add(cred.user) + migrations[cred.name].append(cred.user) + elif cred.team: + cred.owner_role.parents.add(cred.team.admin_role) + cred.usage_role.parents.add(cred.team.member_role) + migrations[cred.name].append(cred.team) + return migrations diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 462cf35249..5d1c0cab96 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -363,14 +363,6 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): update_fields.append('cloud') super(Credential, self).save(*args, **kwargs) - def migrate_to_rbac(self): - if self.user: - self.owner_role.members.add(self.user) - return [self.user] - elif self.team: - self.owner_role.parents.add(self.team.admin_role) - self.usage_role.parents.add(self.team.member_role) - return [self.team] def validate_ssh_private_key(data): """Validate that the given SSH private key or certificate is, diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 173467f258..9de46f8115 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -1,10 +1,16 @@ import pytest +from awx.main.migrations import _rbac as rbac +from django.apps import apps + @pytest.mark.django_db def test_credential_migration_user(credential, user, permissions): u = user('user', False) credential.user = u - migrated = credential.migrate_to_rbac() + credential.save() + + migrated = rbac.migrate_credential(apps, None) + assert len(migrated) == 1 assert credential.accessible_by(u, permissions['admin']) @@ -19,11 +25,13 @@ def test_credential_migration_team_member(credential, team, user, permissions): u = user('user', False) team.admin_role.members.add(u) credential.team = team + credential.save() # No permissions pre-migration assert not credential.accessible_by(u, permissions['admin']) - migrated = credential.migrate_to_rbac() + migrated = rbac.migrate_credential(apps, None) + # Admin permissions post migration assert len(migrated) == 1 assert credential.accessible_by(u, permissions['admin']) @@ -33,12 +41,13 @@ def test_credential_migration_team_admin(credential, team, user, permissions): u = user('user', False) team.member_role.members.add(u) credential.team = team + credential.save() # No permissions pre-migration assert not credential.accessible_by(u, permissions['usage']) # Usage permissions post migration - migrated = credential.migrate_to_rbac() + migrated = rbac.migrate_credential(apps, None) assert len(migrated) == 1 assert credential.accessible_by(u, permissions['usage']) From f29fdf694f4983ffdb16d6ff622684b75a940340 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 16:05:05 -0500 Subject: [PATCH 032/297] convert Team to django migrations --- awx/main/migrations/0004_rbac_migrations.py | 1 + awx/main/migrations/_rbac.py | 8 ++++++++ awx/main/models/organization.py | 6 ------ awx/main/tests/functional/test_rbac_inventory.py | 10 ++++++---- awx/main/tests/functional/test_rbac_team.py | 8 ++++++-- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0004_rbac_migrations.py index 5d02d6bd00..1f9757139a 100644 --- a/awx/main/migrations/0004_rbac_migrations.py +++ b/awx/main/migrations/0004_rbac_migrations.py @@ -14,4 +14,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(rbac.migrate_organization), migrations.RunPython(rbac.migrate_credential), + migrations.RunPython(rbac.migrate_team), ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index b0403b1a0d..05b056c0cd 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -12,6 +12,14 @@ def migrate_organization(apps, schema_editor): migrations[org.name].append(user) return migrations +def migrate_team(apps, schema_editor): + migrations = defaultdict(list) + team = apps.get_model('main', 'Team') + for t in team.objects.all(): + for user in t.users.all(): + t.member_role.members.add(user) + migrations[t.name].append(user) + return migrations def migrate_credential(apps, schema_editor): migrations = defaultdict(list) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 62636e42e6..5b24b304bd 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -134,12 +134,6 @@ class Team(CommonModelNameNotUnique, ResourceMixin): cred.mark_inactive() super(Team, self).mark_inactive(save=save) - def migrate_to_rbac(self): - migrated = [] - for user in self.users.all(): - self.member_role.members.add(user) - migrated.append(user) - return migrated class Permission(CommonModelNameNotUnique): ''' diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 478de37d18..7297aaa2a5 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -1,6 +1,8 @@ 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_inventory_admin_user(inventory, permissions, user): @@ -82,7 +84,7 @@ def test_inventory_admin_team(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['admin']) is False - team_migrations = team.migrate_to_rbac() + team_migrations = rbac.migrate_team(apps, None) migrations = inventory.migrate_to_rbac() assert len(team_migrations) == 1 @@ -107,7 +109,7 @@ def test_inventory_auditor(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - team_migrations = team.migrate_to_rbac() + team_migrations = rbac.migrate_team(apps,None) migrations = inventory.migrate_to_rbac() assert len(team_migrations) == 1 @@ -131,7 +133,7 @@ def test_inventory_updater(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - team_migrations = team.migrate_to_rbac() + team_migrations = rbac.migrate_team(apps,None) migrations = inventory.migrate_to_rbac() assert len(team_migrations) == 1 @@ -156,7 +158,7 @@ def test_inventory_executor(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - team_migrations = team.migrate_to_rbac() + team_migrations = rbac.migrate_team(apps, None) migrations = inventory.migrate_to_rbac() assert len(team_migrations) == 1 diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 42356783f3..72e26d0c37 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -1,13 +1,17 @@ import pytest +from awx.main.migrations import _rbac as rbac +from django.apps import apps + @pytest.mark.django_db def test_team_migration_user(team, user, permissions): u = user('user', False) team.users.add(u) + team.save() assert not team.accessible_by(u, permissions['auditor']) - migrated = team.migrate_to_rbac() + migrated = rbac.migrate_team(apps, None) + assert len(migrated) == 1 assert team.accessible_by(u, permissions['auditor']) - From e71de34cc1304e03434c3a8b566fae4787a2920b Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 16:22:41 -0500 Subject: [PATCH 033/297] convert Inventory to django migrations --- awx/main/migrations/0004_rbac_migrations.py | 1 + awx/main/migrations/_rbac.py | 43 +++++++++++++++++ awx/main/models/inventory.py | 42 ---------------- .../tests/functional/test_rbac_inventory.py | 48 +++++++++---------- 4 files changed, 68 insertions(+), 66 deletions(-) diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0004_rbac_migrations.py index 1f9757139a..31bb92af98 100644 --- a/awx/main/migrations/0004_rbac_migrations.py +++ b/awx/main/migrations/0004_rbac_migrations.py @@ -15,4 +15,5 @@ class Migration(migrations.Migration): migrations.RunPython(rbac.migrate_organization), migrations.RunPython(rbac.migrate_credential), migrations.RunPython(rbac.migrate_team), + migrations.RunPython(rbac.migrate_inventory), ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 05b056c0cd..d2bddc8302 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -33,3 +33,46 @@ def migrate_credential(apps, schema_editor): cred.usage_role.parents.add(cred.team.member_role) migrations[cred.name].append(cred.team) return migrations + +def migrate_inventory(apps, schema_editor): + migrations = defaultdict(dict) + + Inventory = apps.get_model('main', 'Inventory') + Permission = apps.get_model('main', 'Permission') + + for inventory in Inventory.objects.all(): + teams, users = [], [] + for perm in Permission.objects.filter(inventory=inventory): + role = None + execrole = None + if perm.permission_type == 'admin': + role = inventory.admin_role + pass + elif perm.permission_type == 'read': + role = inventory.auditor_role + pass + elif perm.permission_type == 'write': + role = inventory.updater_role + pass + else: + raise Exception('Unhandled permission type for inventory: %s' % perm.permission_type) + if perm.run_ad_hoc_commands: + execrole = inventory.executor_role + + if perm.team: + if role: + perm.team.member_role.children.add(role) + if execrole: + perm.team.member_role.children.add(execrole) + + teams.append(perm.team) + + if perm.user: + if role: + role.members.add(perm.user) + if execrole: + execrole.members.add(perm.user) + users.append(perm.user) + migrations[inventory.name]['teams'] = teams + migrations[inventory.name]['users'] = users + return migrations diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index a31dd76bb9..ead6104870 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -113,48 +113,6 @@ class Inventory(CommonModel, ResourceMixin): role_name='Inventory Executor', ) - def migrate_to_rbac(self): - migrated_users = [] - migrated_teams = [] - - for perm in Permission.objects.filter(inventory=self): - role = None - execrole = None - if perm.permission_type == 'admin': - role = self.admin_role - pass - elif perm.permission_type == 'read': - role = self.auditor_role - pass - elif perm.permission_type == 'write': - role = self.updater_role - pass - else: - raise Exception('Unhandled permission type for inventory: %s' % perm.permission_type) - if perm.run_ad_hoc_commands: - execrole = self.executor_role - - if perm.team: - if role: - perm.team.member_role.children.add(role) - if execrole: - perm.team.member_role.children.add(execrole) - - migrated_teams.append(perm.team) - - if perm.user: - if role: - role.members.add(perm.user) - if execrole: - execrole.members.add(perm.user) - migrated_users.append(perm.user) - - return { - 'migrated_users': migrated_users, - 'migrated_teams': migrated_teams, - } - - def get_absolute_url(self): return reverse('api:inventory_detail', args=(self.pk,)) diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 7297aaa2a5..3d15584afd 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -12,10 +12,10 @@ def test_inventory_admin_user(inventory, permissions, user): assert inventory.accessible_by(u, permissions['admin']) is False - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) - assert len(migrations['migrated_users']) == 1 - assert len(migrations['migrated_teams']) == 0 + assert len(migrations[inventory.name]['users']) == 1 + assert len(migrations[inventory.name]['teams']) == 0 assert inventory.accessible_by(u, permissions['admin']) assert inventory.executor_role.members.filter(id=u.id).exists() is False assert inventory.updater_role.members.filter(id=u.id).exists() is False @@ -29,10 +29,10 @@ def test_inventory_auditor_user(inventory, permissions, user): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) - assert len(migrations['migrated_users']) == 1 - assert len(migrations['migrated_teams']) == 0 + assert len(migrations[inventory.name]['users']) == 1 + assert len(migrations[inventory.name]['teams']) == 0 assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is True assert inventory.executor_role.members.filter(id=u.id).exists() is False @@ -47,10 +47,10 @@ def test_inventory_updater_user(inventory, permissions, user): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) - assert len(migrations['migrated_users']) == 1 - assert len(migrations['migrated_teams']) == 0 + assert len(migrations[inventory.name]['users']) == 1 + assert len(migrations[inventory.name]['teams']) == 0 assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.executor_role.members.filter(id=u.id).exists() is False assert inventory.updater_role.members.filter(id=u.id).exists() @@ -64,10 +64,10 @@ def test_inventory_executor_user(inventory, permissions, user): assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is False - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) - assert len(migrations['migrated_users']) == 1 - assert len(migrations['migrated_teams']) == 0 + assert len(migrations[inventory.name]['users']) == 1 + assert len(migrations[inventory.name]['teams']) == 0 assert inventory.accessible_by(u, permissions['admin']) is False assert inventory.accessible_by(u, permissions['auditor']) is True assert inventory.executor_role.members.filter(id=u.id).exists() @@ -85,12 +85,12 @@ def test_inventory_admin_team(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['admin']) is False team_migrations = rbac.migrate_team(apps, None) - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) assert len(team_migrations) == 1 assert team.member_role.members.count() == 1 - assert len(migrations['migrated_users']) == 0 - assert len(migrations['migrated_teams']) == 1 + assert len(migrations[inventory.name]['users']) == 0 + assert len(migrations[inventory.name]['teams']) == 1 assert inventory.admin_role.members.filter(id=u.id).exists() is False assert inventory.auditor_role.members.filter(id=u.id).exists() is False assert inventory.executor_role.members.filter(id=u.id).exists() is False @@ -110,12 +110,12 @@ def test_inventory_auditor(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = rbac.migrate_team(apps,None) - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) assert len(team_migrations) == 1 assert team.member_role.members.count() == 1 - assert len(migrations['migrated_users']) == 0 - assert len(migrations['migrated_teams']) == 1 + assert len(migrations[inventory.name]['users']) == 0 + assert len(migrations[inventory.name]['teams']) == 1 assert inventory.admin_role.members.filter(id=u.id).exists() is False assert inventory.auditor_role.members.filter(id=u.id).exists() is False assert inventory.executor_role.members.filter(id=u.id).exists() is False @@ -134,12 +134,12 @@ def test_inventory_updater(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = rbac.migrate_team(apps,None) - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) assert len(team_migrations) == 1 assert team.member_role.members.count() == 1 - assert len(migrations['migrated_users']) == 0 - assert len(migrations['migrated_teams']) == 1 + assert len(migrations[inventory.name]['users']) == 0 + assert len(migrations[inventory.name]['teams']) == 1 assert inventory.admin_role.members.filter(id=u.id).exists() is False assert inventory.auditor_role.members.filter(id=u.id).exists() is False assert inventory.executor_role.members.filter(id=u.id).exists() is False @@ -159,12 +159,12 @@ def test_inventory_executor(inventory, permissions, user, team): assert inventory.accessible_by(u, permissions['auditor']) is False team_migrations = rbac.migrate_team(apps, None) - migrations = inventory.migrate_to_rbac() + migrations = rbac.migrate_inventory(apps, None) assert len(team_migrations) == 1 assert team.member_role.members.count() == 1 - assert len(migrations['migrated_users']) == 0 - assert len(migrations['migrated_teams']) == 1 + assert len(migrations[inventory.name]['users']) == 0 + assert len(migrations[inventory.name]['teams']) == 1 assert inventory.admin_role.members.filter(id=u.id).exists() is False assert inventory.auditor_role.members.filter(id=u.id).exists() is False assert inventory.executor_role.members.filter(id=u.id).exists() is False From 0ba7992004e65380377740f10a5b4b4fabf6a58c Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 8 Feb 2016 16:37:05 -0500 Subject: [PATCH 034/297] flake8 fixup --- awx/main/models/inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index ead6104870..e33cec1a23 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -21,7 +21,6 @@ from awx.main.constants import CLOUD_PROVIDERS from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.managers import HostManager from awx.main.models.base import * # noqa -from awx.main.models.organization import Permission # for rbac migration from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * # noqa from awx.main.models.mixins import ResourceMixin From f7dc3c0f0d4b004a6040568bf585cffe08b2d582 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 15:22:00 -0500 Subject: [PATCH 035/297] Added an explicit member role, distinct from auditor role --- awx/main/models/organization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 5b24b304bd..2d61fc81e6 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -62,6 +62,11 @@ class Organization(CommonModel, ResourceMixin): resource_field='resource', permissions = {'read': True} ) + member_role = ImplicitRoleField( + role_name='Organization Member', + resource_field='resource', + permissions = {'read': True} + ) def get_absolute_url(self): From 5008e3faf5621d6490e35b969aadef91103794ed Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 22:44:41 -0500 Subject: [PATCH 036/297] Add parent System roles to organization roles --- awx/main/models/organization.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 2d61fc81e6..2648784236 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -54,11 +54,13 @@ 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} ) From d51447e15839e448f10b24306cac871663d8dce6 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 22:45:57 -0500 Subject: [PATCH 037/297] Migration and tests for super users --- awx/main/migrations/_rbac.py | 10 ++++++++++ awx/main/tests/functional/test_rbac_user.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 awx/main/tests/functional/test_rbac_user.py diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index d2bddc8302..9b97c5a80a 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -1,5 +1,15 @@ from collections import defaultdict +def migrate_users(apps, schema_editor): + migrations = list() + User = apps.get_model('auth', "User") + Role = apps.get_model('main', "Role") + for user in User.objects.all(): + if user.is_superuser: + Role.singleton('System Administrator').members.add(user) + migrations.append(user) + return migrations + def migrate_organization(apps, schema_editor): migrations = defaultdict(list) organization = apps.get_model('main', "Organization") diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py new file mode 100644 index 0000000000..f670b26220 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_user.py @@ -0,0 +1,20 @@ +import pytest + +from awx.main.migrations import _rbac as rbac +from awx.main.models import Role +from django.apps import apps + +@pytest.mark.django_db +def test_user_admin(user_project, project, user): + joe = user('joe', is_superuser = False) + admin = user('admin', is_superuser = True) + sa = Role.singleton('System Administrator') + + assert sa.members.filter(id=joe.id).exists() is False + assert sa.members.filter(id=admin.id).exists() is False + + migrations = rbac.migrate_users(apps, None) + + assert sa.members.filter(id=joe.id).exists() is False + assert sa.members.filter(id=admin.id).exists() is True + assert len(migrations) == 1 From 34067d9c0e63120cc1886bf3da976393c2601bfa Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 22:49:10 -0500 Subject: [PATCH 038/297] Project migration and tests --- awx/main/migrations/_rbac.py | 46 +++++++++++ awx/main/models/projects.py | 5 +- awx/main/tests/functional/conftest.py | 10 +++ .../tests/functional/test_rbac_project.py | 79 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 awx/main/tests/functional/test_rbac_project.py diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 9b97c5a80a..76e4f83336 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -86,3 +86,49 @@ def migrate_inventory(apps, schema_editor): migrations[inventory.name]['teams'] = teams migrations[inventory.name]['users'] = users return migrations + +def migrate_projects(apps, schema_editor): + ''' + I can see projects when: + X I am a superuser. + X I am an admin in an organization associated with the project. + X I am a user in an organization associated with the project. + X I am on a team associated with the project. + X I have been explicitly granted permission to run/check jobs using the + project. + X I created the project but it isn't associated with an organization + I can change/delete when: + X I am a superuser. + X I am an admin in an organization associated with the project. + X I created the project but it isn't associated with an organization + ''' + migrations = defaultdict(lambda: defaultdict(set)) + + Project = apps.get_model('main', 'Project') + Permission = apps.get_model('main', 'Permission') + + for project in Project.objects.all(): + if project.organization is None and project.created_by is not None: + project.admin_role.members.add(project.created_by) + migrations[project.name]['users'].add(project.created_by) + + for team in project.teams.all(): + 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) + + for perm in Permission.objects.filter(project=project): + # 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) + + if perm.user: + project.member_role.members.add(perm.user) + migrations[project.name]['users'].add(perm.user) + + return migrations diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 1da3d51961..593f3e40ca 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -229,13 +229,12 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): ) member_role = ImplicitRoleField( role_name='Project Member', - parent_role='admin', resource_field='resource', - permissions = {'usage': True} + permissions = {'read': True} ) scm_update_role = ImplicitRoleField( role_name='Project Updater', - parent_role='admin', + parent_role='admin_role', resource_field='resource', permissions = {'scm_update': True} ) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 31e6eebf6c..db4143f13d 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -2,6 +2,7 @@ import pytest from awx.main.models.credential import Credential from awx.main.models.inventory import Inventory +from awx.main.models.projects import Project from awx.main.models.organization import ( Organization, Team, @@ -23,6 +24,15 @@ def user(): def team(organization): return Team.objects.create(organization=organization, name='test-team') +@pytest.fixture +def project(organization): + return Project.objects.create(name="test-project", organization=organization, description="test-project-desc") + +@pytest.fixture +def user_project(user): + owner = user('owner') + return Project.objects.create(name="test-user-project", created_by=owner, description="test-user-project-desc") + @pytest.fixture def organization(): return Organization.objects.create(name="test-org", description="test-org-desc") diff --git a/awx/main/tests/functional/test_rbac_project.py b/awx/main/tests/functional/test_rbac_project.py new file mode 100644 index 0000000000..95442036e4 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_project.py @@ -0,0 +1,79 @@ +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_project_user_project(user_project, project, user): + u = user('owner') + 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) + assert len(migrations[user_project.name]['users']) == 1 + assert len(migrations[user_project.name]['teams']) == 0 + assert user_project.accessible_by(u, {'read': True}) is True + assert project.accessible_by(u, {'read': True}) is False + +@pytest.mark.django_db +def test_project_accessible_by_sa(user, project): + u = user('systemadmin', is_superuser=True) + + assert project.accessible_by(u, {'read': True}) is False + 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 + assert project.accessible_by(u, {'read': True, 'write': True}) is True + +@pytest.mark.django_db +def test_project_org_members(user, organization, project): + admin = user('orgadmin') + member = user('orgmember') + + assert project.accessible_by(admin, {'read': True}) is False + assert project.accessible_by(member, {'read': True}) is False + + organization.admin_role.members.add(admin) + organization.member_role.members.add(member) + + rbac.migrate_organization(apps, None) + migrations = rbac.migrate_projects(apps, None) + + assert len(migrations[project.name]['users']) == 0 + assert len(migrations[project.name]['teams']) == 0 + assert project.accessible_by(admin, {'read': True, 'write': True}) is True + assert project.accessible_by(member, {'read': True}) is False + +@pytest.mark.django_db +def test_project_team(user, team, project): + nonmember = user('nonmember') + member = user('member') + + team.users.add(member) + project.teams.add(team) + + assert project.accessible_by(nonmember, {'read': True}) is False + assert project.accessible_by(member, {'read': True}) is False + + rbac.migrate_team(apps, None) + migrations = rbac.migrate_projects(apps, None) + + assert len(migrations[project.name]['users']) == 0 + assert len(migrations[project.name]['teams']) == 1 + assert project.accessible_by(member, {'read': True}) is True + 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') + p.save() + + assert project.accessible_by(u, {'read': True}) is False + + migrations = rbac.migrate_projects(apps, None) + + assert len(migrations[project.name]['users']) == 1 + assert project.accessible_by(u, {'read': True}) is True From a2b9777cc7ec4d606d3a33400c4f242bc9177fab Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 22:51:21 -0500 Subject: [PATCH 039/297] Add migrate_users and migrate_projects to our migration plan --- awx/main/migrations/0004_rbac_migrations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0004_rbac_migrations.py index 31bb92af98..62b90a6783 100644 --- a/awx/main/migrations/0004_rbac_migrations.py +++ b/awx/main/migrations/0004_rbac_migrations.py @@ -12,8 +12,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(rbac.migrate_users), migrations.RunPython(rbac.migrate_organization), migrations.RunPython(rbac.migrate_credential), migrations.RunPython(rbac.migrate_team), migrations.RunPython(rbac.migrate_inventory), + migrations.RunPython(rbac.migrate_projects), ] From a03d48eeb7bf9e6fcf4b6ec564d8c1868b6b867d Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 8 Feb 2016 22:53:22 -0500 Subject: [PATCH 040/297] Add member_role to organizations --- awx/main/migrations/0003_rbac_changes.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/awx/main/migrations/0003_rbac_changes.py b/awx/main/migrations/0003_rbac_changes.py index f26aee850d..0b9de6c100 100644 --- a/awx/main/migrations/0003_rbac_changes.py +++ b/awx/main/migrations/0003_rbac_changes.py @@ -208,6 +208,11 @@ class Migration(migrations.Migration): name='resource', field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), ), + migrations.AddField( + model_name='organization', + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + ), migrations.AddField( model_name='project', name='admin_role', From 3ef9baaa8b8f2615519da3ef281c602c30db72a2 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 9 Feb 2016 10:28:03 -0500 Subject: [PATCH 041/297] added OrganizationAccess tests --- .../functional/test_rbac_organization.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 67422208aa..b9ba96c4c6 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -32,17 +32,39 @@ def test_organization_migration_user(organization, permissions, user): @pytest.mark.django_db def test_organization_access_superuser(organization, user): access = OrganizationAccess(user('admin', True)) + organization.users.add(user('user', False)) + assert access.can_change(organization, None) + assert access.can_delete(organization) + + org = access.get_queryset()[0] + assert len(org.admins.all()) == 0 + assert len(org.users.all()) == 1 + @pytest.mark.django_db def test_organization_access_admin(organization, user): - u = user('admin', False) - organization.admins.add(u) + '''can_change because I am an admin of that org''' + a = user('admin', False) + organization.admins.add(a) + organization.users.add(user('user', False)) - access = OrganizationAccess(u) + access = OrganizationAccess(a) assert access.can_change(organization, None) + assert access.can_delete(organization) + + org = access.get_queryset()[0] + assert len(org.admins.all()) == 1 + assert len(org.users.all()) == 1 @pytest.mark.django_db def test_organization_access_user(organization, user): access = OrganizationAccess(user('user', False)) + organization.users.add(user('user', False)) + assert not access.can_change(organization, None) + assert not access.can_delete(organization) + + org = access.get_queryset()[0] + assert len(org.admins.all()) == 0 + assert len(org.users.all()) == 1 From 9f0e4669dff2f3c9ef1d0ae32cb26fc743ec69eb Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 9 Feb 2016 10:28:15 -0500 Subject: [PATCH 042/297] added TeamAccess tests --- awx/main/tests/functional/test_rbac_team.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 72e26d0c37..2d0e709632 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -1,6 +1,7 @@ import pytest from awx.main.migrations import _rbac as rbac +from awx.main.access import TeamAccess from django.apps import apps @pytest.mark.django_db @@ -15,3 +16,50 @@ def test_team_migration_user(team, user, permissions): assert len(migrated) == 1 assert team.accessible_by(u, permissions['auditor']) + +@pytest.mark.django_db +def test_team_access_superuser(team, user): + team.users.add(user('member', False)) + + access = TeamAccess(user('admin', True)) + + assert access.can_add(None) + assert access.can_change(team, None) + assert access.can_delete(team) + + t = access.get_queryset()[0] + assert len(t.users.all()) == 1 + assert len(t.organization.admins.all()) == 0 + +@pytest.mark.django_db +def test_team_access_org_admin(organization, team, user): + a = user('admin', False) + organization.admins.add(a) + team.organization = organization + team.save() + + access = TeamAccess(a) + assert access.can_add({'organization': organization.pk}) + assert access.can_change(team, None) + assert access.can_delete(team) + + t = access.get_queryset()[0] + assert len(t.users.all()) == 0 + assert len(t.organization.admins.all()) == 1 + +@pytest.mark.django_db +def test_team_access_member(organization, team, user): + u = user('member', False) + team.users.add(u) + team.organization = organization + team.save() + + access = TeamAccess(u) + assert not access.can_add({'organization': organization.pk}) + assert not access.can_change(team, None) + assert not access.can_delete(team) + + t = access.get_queryset()[0] + assert len(t.users.all()) == 1 + assert len(t.organization.admins.all()) == 0 + From 6877a7a56695cfaa1142e8fd206394d7d762b7af Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 15:13:24 -0500 Subject: [PATCH 043/297] added Group.parents rebuilding --- awx/main/fields.py | 4 ++- awx/main/models/inventory.py | 12 +++++--- awx/main/signals.py | 28 ++++++++++++++++++- awx/main/tests/functional/conftest.py | 11 +++++++- .../tests/functional/test_rbac_inventory.py | 23 +++++++++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index b86fef5095..7d903d1278 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -46,8 +46,10 @@ class AutoOneToOneField(models.OneToOneField): def resolve_field(obj, field): for f in field.split('.'): - if obj: + if hasattr(obj, f): obj = getattr(obj, f) + else: + obj = None return obj class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index e33cec1a23..adaca41184 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -544,23 +544,27 @@ class Group(CommonModelNameNotUnique, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Inventory Group Administrator', - parent_role='inventory.admin_role', + 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', + 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' + 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' + parent_role=['inventory.executor_role', 'parents.executor_role'], + resource_field='resource', + permissions = {'read':True, 'execute':True}, ) def __unicode__(self): diff --git a/awx/main/signals.py b/awx/main/signals.py index d5f07170ab..302db7e60b 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -18,6 +18,7 @@ from crum.signals import current_user_getter # AWX from awx.main.models import * # noqa from awx.api.serializers import * # noqa +from awx.main.fields import ImplicitRoleField from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, emit_websocket_notification from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates from awx.main.tasks import update_inventory_computed_fields @@ -122,6 +123,31 @@ def rebuild_role_hierarchy_cache(sender, reverse, model, pk_set, **kwargs): else: kwargs['instance'].rebuild_role_hierarchy_cache() +def rebuild_group_parent_roles(instance, action, reverse, **kwargs): + objects = [] + if reverse: + objects = instance.children.all() + else: + objects = instance.parents.all() + + for obj in objects: + fields = [f for f in instance._meta.get_fields() if type(f) is ImplicitRoleField] + for field in fields: + role = None + if reverse: + if hasattr(obj, field.name): + parent_role = getattr(instance, field.name) + role = getattr(obj, field.name) + else: + role = getattr(instance, field.name) + parent_role = getattr(obj, field.name) + + if role: + if action == 'post_add': + role.parents.add(parent_role) + elif action == 'pre_remove': + role.parents.remove(parent_role) + pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -141,7 +167,7 @@ post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) - +m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index db4143f13d..ca30f8315e 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,7 +1,10 @@ import pytest from awx.main.models.credential import Credential -from awx.main.models.inventory import Inventory +from awx.main.models.inventory import ( + Inventory, + Group, +) from awx.main.models.projects import Project from awx.main.models.organization import ( Organization, @@ -45,6 +48,12 @@ def credential(): def inventory(organization): return Inventory.objects.create(name="test-inventory", organization=organization) +@pytest.fixture +def group(inventory): + def g(name): + return Group.objects.create(inventory=inventory, name=name) + return g + @pytest.fixture def permissions(): return { diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 3d15584afd..8834545140 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -172,3 +172,26 @@ def test_inventory_executor(inventory, permissions, user, team): assert team.member_role.is_ancestor_of(inventory.updater_role) is False assert team.member_role.is_ancestor_of(inventory.executor_role) +@pytest.mark.django_db +def test_group_parent_admin(group, permissions, user): + u = user('admin', False) + parent1 = group('parent-1') + parent2 = group('parent-2') + childA = group('child-1') + + parent1.admin_role.members.add(u) + assert parent1.accessible_by(u, permissions['admin']) + assert not parent2.accessible_by(u, permissions['admin']) + assert not childA.accessible_by(u, permissions['admin']) + + childA.parents.add(parent1) + assert childA.accessible_by(u, permissions['admin']) + + childA.parents.remove(parent1) + assert not childA.accessible_by(u, permissions['admin']) + + parent2.children.add(childA) + assert not childA.accessible_by(u, permissions['admin']) + + parent2.admin_role.members.add(u) + assert childA.accessible_by(u, permissions['admin']) From 86c528154b08e4104f898ae891f358a99ceaea3b Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 16:59:31 -0500 Subject: [PATCH 044/297] Added initial rbac doc --- docs/rbac.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/rbac.md diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000000..84d756ebae --- /dev/null +++ b/docs/rbac.md @@ -0,0 +1,53 @@ +# Role-Based Access Control (RBAC) + +This document describes the RBAC implementation of the Ansible Tower Software. +The intended audience of this document is the Ansible Tower developer. + +## Overview + +The RBAC system allows you to create and layer roles for controlling access to resources. Any `django.Model` can +be made into a `Resource` in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource you can +extend the model definition to have specific roles using the `ImplicitRoleField`. This role field allows you to +configure the name of a role, any parents a role may have, and the permissions having this role will grant you to the resource. + +### Roles + +Roles are defined for a resource. If a role has any parents, these parents will be considered when determing +what roles are checked when accessing a resource. + + ResourceA + |-- AdminRole + + ResourceB + | -- AdminRole + |-- parent = ResourceA.AdminRole + +When a user attempts to access ResourceB we will check for their level access using the set of all unique roles, include the parents. + + set: ResourceA.AdminRole, ResourceB.AdminRole + +This would provide anyone with the ResourceA.AdminRole or ResourceB.AdminRole access to ResourceB. + +## Models + +`Role` + +`RoleHierarchy` + +`Resource` + +`RolePermission` + +## Fields + +`ImplicitRoleField` + +`ImplicitResourceField` + +## Mixins + +`ResourceMixin` + +Usage +----- + From 445078166212880202864d7cbfe7b5267c42f512 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 17:11:24 -0500 Subject: [PATCH 045/297] Update rbac.md Fixing typo --- docs/rbac.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rbac.md b/docs/rbac.md index 84d756ebae..84aa35ab26 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -22,7 +22,7 @@ what roles are checked when accessing a resource. | -- AdminRole |-- parent = ResourceA.AdminRole -When a user attempts to access ResourceB we will check for their level access using the set of all unique roles, include the parents. +When a user attempts to access ResourceB we will check for their access using the set of all unique roles, include the parents. set: ResourceA.AdminRole, ResourceB.AdminRole From a0f317928d7a988eb27668d431b0f3dee490180e Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 18:12:54 -0500 Subject: [PATCH 046/297] Update rbac.md Continue to flesh out more of the rbac documentation and examples. --- docs/rbac.md | 64 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 84aa35ab26..7eecf36526 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -5,7 +5,7 @@ The intended audience of this document is the Ansible Tower developer. ## Overview -The RBAC system allows you to create and layer roles for controlling access to resources. Any `django.Model` can +The RBAC system allows you to create and layer roles for controlling access to resources. Any django Model can be made into a `Resource` in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource you can extend the model definition to have specific roles using the `ImplicitRoleField`. This role field allows you to configure the name of a role, any parents a role may have, and the permissions having this role will grant you to the resource. @@ -26,17 +26,19 @@ When a user attempts to access ResourceB we will check for their access using th set: ResourceA.AdminRole, ResourceB.AdminRole -This would provide anyone with the ResourceA.AdminRole or ResourceB.AdminRole access to ResourceB. +This would provide anyone with the above roles access to ResourceB. ## Models -`Role` +The RBAC system defines a few new models. Each model -`RoleHierarchy` +### `Role` -`Resource` +### `Resource` -`RolePermission` +### `RoleHierarchy` + +### `RolePermission` ## Fields @@ -46,8 +48,52 @@ This would provide anyone with the ResourceA.AdminRole or ResourceB.AdminRole ac ## Mixins -`ResourceMixin` +### `ResourceMixin` + +By mixing in the `ResourceMixin` to your model, you are turning your model in to a `Resource` in the eyes of the RBAC implementation. What this means simply is that your model will now have an `ImplicitResourceField` named resource. Your model will also gain some methods that aid in the checking the access a users roles provides them to a resource. + +#### `accessible_objects(cls, user, permissions)` + +`accessible_objects` is a class level method to use instead of `Model.objects`. This method will restrict the query of objects to only the objects that a user has the passed in permissions for. This is useful when you want to only filter and display a `Resource` that a users role grants them the `permissions` to. + +```python + objects = Model.accessible_objects(user, {'write':True}) + objects.filter(name__istartswith='december') +``` + +#### `get_permissions(self, user)` + +#### `accessible_by(self, user, permissions)` + +## Usage + +After exploring the _Overview_ the usage of the RBAC implementation in your code should feel unintrisive and natural. + +```python + # make your model a Resource + class Document(Model, ResourceMixin): + ... + # declare your new role + readonly_role = ImplicitRoleField( + role_name="readonly", + resource_field="resource", + permissions={'read':True}, + ) +``` + +Now that your model is a `Resource` and has a `Role` defined, you can be get to access the helper methods provided to you by the `ResourceMixin` for checking access to your resource. Here is the output of a Python REPL session. + +```python + # we've created some documents and a user + >>> document = Document.objects.filter(pk=1) + >>> user = User.objects.first() + >>> document.accessible_by(user, {'read': True}) + False # not accessible by default + >>> document.readonly_role.memebers.add(user) + >>> document.accessible_by(user, {'read':True}) + True # now it is accessible + >>> document.accessible_by(user, {'read':True, 'write':True}) + False # my role does not have write permission +``` -Usage ------ From 6bf81b5d11932bb54eb95ee05d53006d7c211df5 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 18:40:08 -0500 Subject: [PATCH 047/297] Update rbac.md Added more details about the mixin helper methods. --- docs/rbac.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 7eecf36526..ee0e2f1e20 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -30,7 +30,7 @@ This would provide anyone with the above roles access to ResourceB. ## Models -The RBAC system defines a few new models. Each model +The RBAC system defines a few new models. These models represent the underlying RBAC implemnentation and generally will be abstracted away from your daily development tasks by the implicict fields and mixins. ### `Role` @@ -54,7 +54,7 @@ By mixing in the `ResourceMixin` to your model, you are turning your model in to #### `accessible_objects(cls, user, permissions)` -`accessible_objects` is a class level method to use instead of `Model.objects`. This method will restrict the query of objects to only the objects that a user has the passed in permissions for. This is useful when you want to only filter and display a `Resource` that a users role grants them the `permissions` to. +`accessible_objects` is a class method to use instead of `Model.objects`. This method will restrict the query of objects to only the objects that a user has the passed in permissions for. This is useful when you want to only filter and display a `Resource` that a users role grants them the `permissions` to. Note that any permission fields that are left blank will default to `False`. `accessible_objects` will only filter out resources where the expected permission was `True` but was returned as `False`. ```python objects = Model.accessible_objects(user, {'write':True}) @@ -63,8 +63,23 @@ By mixing in the `ResourceMixin` to your model, you are turning your model in to #### `get_permissions(self, user)` +`get_permissions` is an instance method that will give you the permission dictionary for a given user. This permission dictionary will take in to account any parent roles the user is apart of. + +```python + >>> instance.get_permissions(admin) + {'create':True, 'read':True, 'write':True, 'update':True, + 'delete':True, 'scm_update':True, 'execute':True, 'use':True} +``` + + #### `accessible_by(self, user, permissions)` +`accessible_by` is an instance method that wraps the `get_permissions` method. Given a user and a dictionary of permissions this method will return True or False if a users roles give them a set of permissions that match the provided permissions dict. Not that any permission fields left blank will default to `False`. `accessible_by` will only return `False` in a case where the passed in permission is expected to be `True` but was returned as `False`. + +```python + >>> instance.accessible_by(admin, {'use':True, 'read':True}) + True +``` ## Usage After exploring the _Overview_ the usage of the RBAC implementation in your code should feel unintrisive and natural. From e067c4a7c3afa8264bb57a64a0c2de005c0ade68 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 10 Feb 2016 18:41:46 -0500 Subject: [PATCH 048/297] Update rbac.md Fixing some misspellings / typos. --- docs/rbac.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index ee0e2f1e20..dbef438298 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -42,9 +42,9 @@ The RBAC system defines a few new models. These models represent the underlying ## Fields -`ImplicitRoleField` +### `ImplicitRoleField` -`ImplicitResourceField` +### `ImplicitResourceField` ## Mixins @@ -82,7 +82,7 @@ By mixing in the `ResourceMixin` to your model, you are turning your model in to ``` ## Usage -After exploring the _Overview_ the usage of the RBAC implementation in your code should feel unintrisive and natural. +After exploring the _Overview_ the usage of the RBAC implementation in your code should feel unobtrusive and natural. ```python # make your model a Resource From 1ed0c94c6292436909ebe5b6a540f2bf53eecc48 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 11 Feb 2016 11:06:46 -0500 Subject: [PATCH 049/297] Update rbac.md Added information about the ImplicitRoleField --- docs/rbac.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index dbef438298..197e8c12f0 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -30,7 +30,7 @@ This would provide anyone with the above roles access to ResourceB. ## Models -The RBAC system defines a few new models. These models represent the underlying RBAC implemnentation and generally will be abstracted away from your daily development tasks by the implicict fields and mixins. +The RBAC system defines a few new models. These models represent the underlying RBAC implemnentation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins. ### `Role` @@ -44,6 +44,35 @@ The RBAC system defines a few new models. These models represent the underlying ### `ImplicitRoleField` +`ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your +`Resource`. Configuring the role is done using some keyword arguments that are provided during declaration. + +`resource_field` is the name of the field in your model that is a `ForeignKey` to a `Resource`. If you use the 'ResourceMixin', this field is added to your model for you and is called `resource`. This field is required for the RBAC implementation to integrate any of the role fields you declare for your model. If you did not use the `ResourceMixin` and you have manually added a `Resource` link to your model you will need to set this field accordingly. + +`parent_role` is the link to any parent roles you want considered when a user is requesting access to your `Resource`. A `parent_role` can be declared as a single string, `parent.readonly`, or a list of many roles, `['parentA.readonly', 'parentB.readonly']`. It is important to note that a user does not need a parent role to access a resource if granted the role for that resource explicitly. Also a user will not have access to any parent resources by being granted a role for a child resource. We demonstrate this in the _Usage_ section of this document. + +`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`. + +```python + # Available Permissions + { + 'create':True, + 'read':True, + 'write':True, + 'update':True, + 'delete':True, + 'scm_update':True, + 'use':True, + 'execute':True, + } + # Special Permissions + {'all':True} + # Example: readonly + {'read':True} +``` + ### `ImplicitResourceField` ## Mixins @@ -96,7 +125,7 @@ After exploring the _Overview_ the usage of the RBAC implementation in your code ) ``` -Now that your model is a `Resource` and has a `Role` defined, you can be get to access the helper methods provided to you by the `ResourceMixin` for checking access to your resource. Here is the output of a Python REPL session. +Now that your model is a `Resource` and has a `Role` defined, you can begin to access the helper methods provided to you by the `ResourceMixin` for checking a users access to your resource. Here is the output of a Python REPL session. ```python # we've created some documents and a user From 25c48c0077b4044da2e80915a8946b330ccbad14 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 11 Feb 2016 11:07:44 -0500 Subject: [PATCH 050/297] Update rbac.md Quick style change. --- docs/rbac.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 197e8c12f0..77c9674ffd 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -57,16 +57,8 @@ The RBAC system defines a few new models. These models represent the underlying ```python # Available Permissions - { - 'create':True, - 'read':True, - 'write':True, - 'update':True, - 'delete':True, - 'scm_update':True, - 'use':True, - 'execute':True, - } + {'create':True, 'read':True, 'write':True, 'update':True, + 'delete':True, 'scm_update':True, 'use':True, 'execute':True} # Special Permissions {'all':True} # Example: readonly From ac7d50048cd2584244f9146015808703eb5973f1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 10 Feb 2016 16:09:57 -0500 Subject: [PATCH 051/297] Removing unused resource_parent Forgot to remove these bits when we removed the concept a few commits ago --- awx/main/fields.py | 18 ++---------------- awx/main/models/rbac.py | 2 -- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 7d903d1278..e002ab74c9 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -55,8 +55,7 @@ def resolve_field(obj, field): class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): """Descriptor for access to the object from its related class.""" - def __init__(self, parent_resource, *args, **kwargs): - self.parent_resource = parent_resource + def __init__(self, *args, **kwargs): super(ResourceFieldDescriptor, self).__init__(*args, **kwargs) def __get__(self, instance, instance_type=None): @@ -64,18 +63,6 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): if resource: return resource resource = Resource._default_manager.create() - if self.parent_resource: - # Take first non null parent resource - parent = None - if type(self.parent_resource) is list: - for path in self.parent_resource: - parent = resolve_field(instance, path) - if parent: - break - else: - parent = resolve_field(instance, self.parent_resource) - resource.parent = parent - resource.save() setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) return resource @@ -84,8 +71,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): class ImplicitResourceField(models.ForeignKey): """Creates an associated resource object if one doesn't already exist""" - def __init__(self, parent_resource=None, *args, **kwargs): - self.parent_resource = parent_resource + def __init__(self, *args, **kwargs): kwargs.setdefault('to', 'Resource') kwargs.setdefault('related_name', '+') kwargs.setdefault('null', 'True') diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 75ff67cb96..1268f68443 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -113,8 +113,6 @@ class Resource(CommonModelNameNotUnique): verbose_name_plural = _('resources') db_table = 'main_rbac_resources' - parent = models.ForeignKey('Resource', related_name='children', null=True, default=None) - class RolePermission(CreatedModifiedModel): ''' From 9a3ef6b99851cb86ffb1ca82d78a5eaa53e33471 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 11 Feb 2016 16:13:41 -0500 Subject: [PATCH 052/297] ORMified RBAC classes; Added GenericForeignKey backref for convenience The RoleHierarchy table has been eliminated in favor of just using a ManyToMany map, which is what we should have been using all along. ORMifications still need improvement, in particular filtering on ResourceMixin.accessible_by should reduce permission calculation overhead, but with the current implemenation this is not true. ResourceMixin.get_permission performs adequately but not as good as it can yet. --- awx/main/models/mixins.py | 250 +++++++++++++++++++++++++------------- awx/main/models/rbac.py | 64 +++++----- 2 files changed, 197 insertions(+), 117 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 8ce444bbb4..3b7b5eb8d4 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -1,11 +1,14 @@ # Django from django.db import models from django.db import connection +from django.db.models.aggregates import Max +from django.contrib.contenttypes.models import ContentType # AWX -from awx.main.models.rbac import RolePermission, Role, RoleHierarchy +from awx.main.models.rbac import RolePermission, Role, Resource from awx.main.fields import ImplicitResourceField + __all__ = 'ResourceMixin' class ResourceMixin(models.Model): @@ -29,62 +32,105 @@ class ResourceMixin(models.Model): `myresource.get_permissions(user)`. ''' - aggregate_where_clause = '' - aggregates = '' - group_clause = '' - where_clause = '' + # TODO: Clean this up once we have settled on an optimal implementation - if len(permissions) > 1: - group_clause = 'GROUP BY %s.resource_id' % RolePermission._meta.db_table + if False: + # This query does not work, but it is not apparent to me at this + # time why it does not. The intent is to be able to return a query + # set that does not involve a subselect, with the hope that this + # will perform better than our subselect method. I'm leaving it + # here for now so that I can revisit it with fresh eyes and + # hopefully find the issue. + # + # If someone else comes along and fixes or eliminates this, please + # tag me or let me know! - anoek 2016-02-11 + + qs = cls.objects for perm in permissions: - if not aggregate_where_clause: - aggregate_where_clause = 'WHERE ' - else: - aggregate_where_clause += ' AND ' - aggregate_where_clause += '"%s" = %d' % (perm, int(permissions[perm])) - aggregates += ', MAX("%s") as "%s"' % (perm, perm) - if len(permissions) == 1: - perm = list(permissions.keys())[0] - where_clause = 'AND "%s" = %d' % (perm, int(permissions[perm])) + kw = {'max_' + perm: Max('resource__permissions__' + perm)} + qs = qs.annotate(**kw) + kw = {'max_' + perm: int(permissions[perm])} + qs = qs.filter(**kw) + qs = qs.filter(resource__permissions__role__ancestors__members=user) + return qs - return cls.objects.extra( - where=[ - ''' - %(table_name)s.resource_id in ( - SELECT resource_id FROM ( - SELECT %(rbac_permission)s.resource_id %(aggregates)s - FROM %(rbac_role)s_members - LEFT JOIN %(rbac_role_hierachy)s - ON (%(rbac_role_hierachy)s.ancestor_id = %(rbac_role)s_members.role_id) - LEFT JOIN %(rbac_permission)s - ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) - WHERE %(rbac_role)s_members.user_id=%(user_id)d - %(where_clause)s - %(group_clause)s - ) summarized_permissions - %(aggregate_where_clause)s - ) - ''' - % - { - 'table_name' : cls._meta.db_table, - 'aggregates' : aggregates, - 'user_id' : user.id, - 'aggregate_where_clause' : aggregate_where_clause, - 'group_clause' : group_clause, - 'where_clause' : where_clause, - 'rbac_role' : Role._meta.db_table, - 'rbac_permission' : RolePermission._meta.db_table, - 'rbac_role_hierachy' : RoleHierarchy._meta.db_table - } - ] - ) + if True: + # Begin working code.. this results in a subselect which I'm not too + # thrilled about, but performs reasonably ok + + qs = Resource.objects.filter( + content_type=ContentType.objects.get_for_model(cls), + permissions__role__ancestors__members=user + ) + for perm in permissions: + kw = {'max_' + perm: Max('permissions__' + perm)} + qs = qs.annotate(**kw) + kw = {'max_' + perm: int(permissions[perm])} + qs = qs.filter(**kw) + + return cls.objects.filter(resource__in=qs) + + if False: + # This works and remains the most performant implementation. Keeping it here + # until we can dethrone it with a proper ORM implementation + + aggregate_where_clause = '' + aggregates = '' + group_clause = '' + where_clause = '' + + if len(permissions) > 1: + group_clause = 'GROUP BY %s.resource_id' % RolePermission._meta.db_table + for perm in permissions: + if not aggregate_where_clause: + aggregate_where_clause = 'WHERE ' + else: + aggregate_where_clause += ' AND ' + aggregate_where_clause += '"%s" = %d' % (perm, int(permissions[perm])) + aggregates += ', MAX("%s") as "%s"' % (perm, perm) + if len(permissions) == 1: + perm = list(permissions.keys())[0] + where_clause = 'AND "%s" = %d' % (perm, int(permissions[perm])) + + return cls.objects.extra( + where=[ + ''' + %(table_name)s.resource_id in ( + SELECT resource_id FROM ( + SELECT %(rbac_permission)s.resource_id %(aggregates)s + FROM %(rbac_role)s_members + LEFT JOIN %(rbac_role_hierachy)s + ON (%(rbac_role_hierachy)s.to_role_id = %(rbac_role)s_members.role_id) + LEFT JOIN %(rbac_permission)s + ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.from_role_id) + WHERE %(rbac_role)s_members.user_id=%(user_id)d + %(where_clause)s + %(group_clause)s + order by resource_id + ) summarized_permissions + %(aggregate_where_clause)s + ) + ''' + % + { + 'table_name' : cls._meta.db_table, + 'aggregates' : aggregates, + 'user_id' : user.id, + 'aggregate_where_clause' : aggregate_where_clause, + 'group_clause' : group_clause, + 'where_clause' : where_clause, + 'rbac_role' : Role._meta.db_table, + 'rbac_permission' : RolePermission._meta.db_table, + 'rbac_role_hierachy' : 'main_rbac_roles_ancestors' + } + ] + ) def get_permissions(self, user): ''' Returns a dict (or None) of the permissions a user has for a given - resource. + resource. Note: Each field in the dict is the `or` of all respective permissions that have been granted to the roles that are applicable for the given @@ -96,45 +142,79 @@ class ResourceMixin(models.Model): access. ''' + # TODO: Clean this up once we have settled on an optimal implementation - with connection.cursor() as cursor: - cursor.execute( - ''' - SELECT - MAX("create") as "create", - MAX("read") as "read", - MAX("write") as "write", - MAX("update") as "update", - MAX("delete") as "delete", - MAX("scm_update") as "scm_update", - MAX("execute") as "execute", - MAX("use") as "use" + if True: + # This works well enough at scale, but is about 5x slower than the + # raw sql variant, further optimization is desired. - FROM %(rbac_permission)s - LEFT JOIN %(rbac_role_hierachy)s - ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.role_id) - INNER JOIN %(rbac_role)s_members - ON ( - %(rbac_role)s_members.role_id = %(rbac_role_hierachy)s.ancestor_id - AND %(rbac_role)s_members.user_id = %(user_id)d - ) + qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self.resource) + + qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) + qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) + qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) + qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) + qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) + qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) + qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) + qs = qs.annotate(max_use = Max('roles__descendents__permissions__use')) + + qs = qs.values('max_create', 'max_read', 'max_write', 'max_update', + 'max_delete', 'max_scm_update', 'max_execute', 'max_use') + + #print('###############') + #print(qs.query) + #print('###############') + + res = qs.all() + if len(res): + return {k[4:]:v for k,v in res[0].items()} + return None + + + if False: + # This works and remains the most performant implementation. Keeping it here + # until we can dethrone it with a proper ORM implementation + + with connection.cursor() as cursor: + cursor.execute( + ''' + SELECT + MAX("create") as "create", + MAX("read") as "read", + MAX("write") as "write", + MAX("update") as "update", + MAX("delete") as "delete", + MAX("scm_update") as "scm_update", + MAX("execute") as "execute", + MAX("use") as "use" + + FROM %(rbac_permission)s + LEFT JOIN main_rbac_roles_ancestors + ON (%(rbac_permission)s.role_id = main_rbac_roles_ancestors.from_role_id) + INNER JOIN %(rbac_role)s_members + ON ( + %(rbac_role)s_members.role_id = main_rbac_roles_ancestors.to_role_id + AND %(rbac_role)s_members.user_id = %(user_id)d + ) + + WHERE %(rbac_permission)s.resource_id=%(resource_id)s + GROUP BY %(rbac_role)s_members.user_id + ''' + % + { + 'user_id': user.id, + 'resource_id': self.resource.id, + 'rbac_role': Role._meta.db_table, + 'rbac_permission': RolePermission._meta.db_table, + #'rbac_role_hierachy': RoleHierarchy._meta.db_table + } + ) + row = cursor.fetchone() + if row: + return dict(zip([x[0] for x in cursor.description], row)) + return None - WHERE %(rbac_permission)s.resource_id=%(resource_id)s - GROUP BY %(rbac_role)s_members.user_id - ''' - % - { - 'user_id': user.id, - 'resource_id': self.resource.id, - 'rbac_role': Role._meta.db_table, - 'rbac_permission': RolePermission._meta.db_table, - 'rbac_role_hierachy': RoleHierarchy._meta.db_table - } - ) - row = cursor.fetchone() - if row: - return dict(zip([x[0] for x in cursor.description], row)) - return None def accessible_by(self, user, permissions): ''' diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 1268f68443..1a5c189892 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -7,11 +7,13 @@ import logging # Django from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey # AWX from awx.main.models.base import * # noqa -__all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy'] +__all__ = ['Role', 'RolePermission', 'Resource'] logger = logging.getLogger('awx.main.models.rbac') @@ -28,32 +30,41 @@ class Role(CommonModelNameNotUnique): singleton_name = models.TextField(null=True, default=None, db_index=True, unique=True) parents = models.ManyToManyField('Role', related_name='children') + ancestors = models.ManyToManyField('Role', related_name='descendents') # auto-generated by `rebuild_role_ancestor_list` members = models.ManyToManyField('auth.User', related_name='roles') + content_type = models.ForeignKey(ContentType, null=True, default=None) + object_id = models.PositiveIntegerField(null=True, default=None) + content_object = GenericForeignKey('content_type', 'object_id') def save(self, *args, **kwargs): super(Role, self).save(*args, **kwargs) - self.rebuild_role_hierarchy_cache() + self.rebuild_role_ancestor_list() - def rebuild_role_hierarchy_cache(self): - 'Rebuilds the associated entries in the RoleHierarchy model' + def rebuild_role_ancestor_list(self): + ''' + Updates our `ancestors` map to accurately reflect all of the ancestors for a role - # Compute what our hierarchy should be. (Note: this depends on our - # parent's cached hierarchy being correct) - parent_ids = set([parent.id for parent in self.parents.all()]) - actual_ancestors = set([r.ancestor.id for r in RoleHierarchy.objects.filter(role__id__in=parent_ids)]) + You should never need to call this. Signal handlers should be calling + this method when the role hierachy changes automatically. + + Note that this method relies on any parents' ancestor list being correct. + ''' + + actual_ancestors = set(Role.objects.filter(id=self.id).values_list('parents__ancestors__id', flat=True)) actual_ancestors.add(self.id) - - # Compute what we have stored - stored_ancestors = set([r.ancestor.id for r in RoleHierarchy.objects.filter(role__id=self.id)]) + if None in actual_ancestors: + actual_ancestors.remove(None) + stored_ancestors = set(self.ancestors.all().values_list('id', flat=True)) # If it differs, update, and then update all of our children if actual_ancestors != stored_ancestors: - RoleHierarchy.objects.filter(role__id=self.id).delete() - for id in actual_ancestors: - rh = RoleHierarchy(role=self, ancestor=Role.objects.get(id=id)) - rh.save() + for id in actual_ancestors - stored_ancestors: + self.ancestors.add(Role.objects.get(id=id)) + for id in stored_ancestors - actual_ancestors: + self.ancestors.remove(Role.objects.get(id=id)) + for child in self.children.all(): - child.rebuild_role_hierarchy_cache() + child.rebuild_role_ancestor_list() def grant(self, resource, permissions): # take either the raw Resource or something that includes the ResourceMixin @@ -85,22 +96,7 @@ class Role(CommonModelNameNotUnique): return ret def is_ancestor_of(self, role): - return RoleHierarchy.objects.filter(role_id=role.id, ancestor_id=self.id).count() > 0 - - - -class RoleHierarchy(CreatedModifiedModel): - ''' - Stores a flattened relation map of all roles in the system for easy joining - ''' - - class Meta: - app_label = 'main' - verbose_name_plural = _('role_ancestors') - db_table = 'main_rbac_role_hierarchy' - - role = models.ForeignKey('Role', related_name='+', on_delete=models.CASCADE) - ancestor = models.ForeignKey('Role', related_name='+', on_delete=models.CASCADE) + return role.ancestors.filter(id=self.id).exists() class Resource(CommonModelNameNotUnique): @@ -113,6 +109,10 @@ class Resource(CommonModelNameNotUnique): verbose_name_plural = _('resources') db_table = 'main_rbac_resources' + content_type = models.ForeignKey(ContentType, null=True, default=None) + object_id = models.PositiveIntegerField(null=True, default=None) + content_object = GenericForeignKey('content_type', 'object_id') + class RolePermission(CreatedModifiedModel): ''' From 72419f7eb9f1454b677467367a9d0d05623e4fe1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 11 Feb 2016 16:59:32 -0500 Subject: [PATCH 053/297] Generically handle automatic role rebinding through m2m relations --- awx/main/fields.py | 129 ++++++++++++++++++++++++++++++++++---------- awx/main/signals.py | 35 ++---------- 2 files changed, 107 insertions(+), 57 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index e002ab74c9..69a5cfa089 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -3,17 +3,26 @@ # Django from django.db.models.signals import post_save +from django.db.models.signals import m2m_changed from django.db import models -from django.db.models.fields.related import SingleRelatedObjectDescriptor -from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor +from django.db.models.fields.related import ( + add_lazy_relation, + SingleRelatedObjectDescriptor, + ReverseSingleRelatedObjectDescriptor, + ManyRelatedObjectsDescriptor, + ReverseManyRelatedObjectsDescriptor, +) + from django.core.exceptions import FieldError + # AWX from awx.main.models.rbac import Resource, RolePermission, Role __all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField'] + # Based on AutoOneToOneField from django-annoying: # https://bitbucket.org/offline/django-annoying/src/a0de8b294db3/annoying/fields.py @@ -44,14 +53,6 @@ class AutoOneToOneField(models.OneToOneField): -def resolve_field(obj, field): - for f in field.split('.'): - if hasattr(obj, f): - obj = getattr(obj, f) - else: - obj = None - return obj - class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): """Descriptor for access to the object from its related class.""" @@ -62,7 +63,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) if resource: return resource - resource = Resource._default_manager.create() + resource = Resource._default_manager.create(content_object=instance) setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) return resource @@ -79,7 +80,7 @@ class ImplicitResourceField(models.ForeignKey): def contribute_to_class(self, cls, name): super(ImplicitResourceField, self).contribute_to_class(cls, name) - setattr(cls, self.name, ResourceFieldDescriptor(self.parent_resource, self)) + setattr(cls, self.name, ResourceFieldDescriptor(self)) post_save.connect(self._save, cls, True) def _save(self, instance, *args, **kwargs): @@ -106,23 +107,38 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if not self.role_name: raise FieldError('Implicit role missing `role_name`') - role = Role._default_manager.create(name=self.role_name) + role = Role._default_manager.create(name=self.role_name, content_object=instance) if self.parent_role: - # Add all non-null parent roles as parents - if type(self.parent_role) is list: - for path in self.parent_role: - if path.startswith("singleton:"): - parent = Role.singleton(path[10:]) - else: - parent = resolve_field(instance, path) - if parent: - role.parents.add(parent) - else: - if self.parent_role.startswith("singleton:"): - parent = Role.singleton(self.parent_role[10:]) + def resolve_field(obj, field): + ret = [] + + field_components = field.split('.', 1) + if hasattr(obj, field_components[0]): + obj = getattr(obj, field_components[0]) else: - parent = resolve_field(instance, self.parent_role) - if parent: + return [] + + if len(field_components) == 1: + if type(obj) is not ImplicitRoleDescriptor and type(obj) is not Role: + raise Exception('%s refers to a %s, not an ImplicitRoleField or Role' % (field, str(type(obj)))) + ret.append(obj) + else: + if type(obj) is ManyRelatedObjectsDescriptor: + for o in obj.all(): + ret += resolve_field(o, field_components[1]) + else: + ret += resolve_field(obj, field_components[1]) + + return ret + + # Add all non-null parent roles as parents + paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] + for path in paths: + if path.startswith("singleton:"): + parents = [Role.singleton(path[10:])] + else: + parents = resolve_field(instance, path) + for parent in parents: role.parents.add(parent) setattr(instance, self.field.name, role) instance.save(update_fields=[self.field.name,]) @@ -178,6 +194,65 @@ class ImplicitRoleField(models.ForeignKey): ) ) post_save.connect(self._save, cls, True) + add_lazy_relation(cls, self, "self", self.bind_m2m_changed) + + def bind_m2m_changed(self, _self, _role_class, cls): + if not self.parent_role: + return + field_names = self.parent_role + if type(field_names) is not list: + field_names = [field_names] + + found_m2m_field = False + for field_name in field_names: + if field_name.startswith('singleton:'): + continue + + first_field_name = field_name.split('.')[0] + field = getattr(cls, first_field_name) + + if type(field) is ReverseManyRelatedObjectsDescriptor: + 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 + # encapsulates what field we're working with. So the workaround that I've + # settled on for the time being is to simply iterate over the fields in the + # m2m_update and look for the m2m map and update accordingly. This solution + # is lame, I'd love for someone to show me the way to get the necessary + # state down to the m2m_update callback. - anoek 2016-02-10 + raise Exception('Multiple ManyToMany fields not allowed in parent_role list') + if len(field_name.split('.')) != 2: + raise Exception('Referencing deep roles through ManyToMany fields is unsupported.') + + 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)) + + + def m2m_update(self, sender, instance, action, reverse, model, pk_set, **kwargs): + if action == 'post_add' or action == 'pre_remove': + if reverse: + for pk in pk_set: + obj = model.objects.get(pk=pk) + if action == 'post_add': + getattr(instance, self.name).children.add(getattr(obj, self.m2m_field_attr)) + if action == 'pre_remove': + getattr(instance, self.name).children.remove(getattr(obj, self.m2m_field_attr)) + + else: + for pk in pk_set: + obj = model.objects.get(pk=pk) + if action == 'post_add': + getattr(instance, self.name).parents.add(getattr(obj, self.m2m_field_attr)) + if action == 'pre_remove': + getattr(instance, self.name).parents.remove(getattr(obj, self.m2m_field_attr)) + def _save(self, instance, *args, **kwargs): # Ensure that our field gets initialized after our first save diff --git a/awx/main/signals.py b/awx/main/signals.py index 302db7e60b..f5778dbb2e 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -18,7 +18,6 @@ from crum.signals import current_user_getter # AWX from awx.main.models import * # noqa from awx.api.serializers import * # noqa -from awx.main.fields import ImplicitRoleField from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, emit_websocket_notification from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates from awx.main.tasks import update_inventory_computed_fields @@ -116,37 +115,13 @@ def store_initial_active_state(sender, **kwargs): else: instance._saved_active_state = True -def rebuild_role_hierarchy_cache(sender, reverse, model, pk_set, **kwargs): +def rebuild_role_ancestor_list(sender, reverse, model, instance, pk_set, **kwargs): if reverse: for id in pk_set: - model.objects.get(id=id).rebuild_role_hierarchy_cache() + model.objects.get(id=id).rebuild_role_ancestor_list() else: - kwargs['instance'].rebuild_role_hierarchy_cache() + instance.rebuild_role_ancestor_list() -def rebuild_group_parent_roles(instance, action, reverse, **kwargs): - objects = [] - if reverse: - objects = instance.children.all() - else: - objects = instance.parents.all() - - for obj in objects: - fields = [f for f in instance._meta.get_fields() if type(f) is ImplicitRoleField] - for field in fields: - role = None - if reverse: - if hasattr(obj, field.name): - parent_role = getattr(instance, field.name) - role = getattr(obj, field.name) - else: - role = getattr(instance, field.name) - parent_role = getattr(obj, field.name) - - if role: - if action == 'post_add': - role.parents.add(parent_role) - elif action == 'pre_remove': - role.parents.remove(parent_role) pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -166,8 +141,8 @@ post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) -m2m_changed.connect(rebuild_role_hierarchy_cache, Role.parents.through) -m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) +m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) +#m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. From 10e73ebc2e2e159aa19be647096d777dcc962842 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 12 Feb 2016 09:41:30 -0500 Subject: [PATCH 054/297] Update rbac.md Updated Role model docs --- docs/rbac.md | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 77c9674ffd..f1877b7c1c 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -28,21 +28,39 @@ When a user attempts to access ResourceB we will check for their access using th This would provide anyone with the above roles access to ResourceB. -## Models +#### Singleton Role + +There is a special case _Singleton Role_ that you can create. This type of role is for system wide roles. + +### Models The RBAC system defines a few new models. These models represent the underlying RBAC implemnentation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins. -### `Role` +#### `Role` -### `Resource` +`Role` defines a single role within the RBAC implementation. It encapsulates the `parents` and `members` for a role. This model is intentially kepts dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. -### `RoleHierarchy` +##### `grant(self, resource, permissions)` -### `RolePermission` +The `grant` instance method takes a resource and a set of permissions (see below) and creates an entry in the `RolePermission` table (described below). The result of this being that any member of this role will now have those granted permissions to the resource. The `grant` method considers a resource to be anything that is explicitly of the `Resource` type or any model that has a `resource` field that is of type `Resource`. -## Fields +##### `singleton(name)` -### `ImplicitRoleField` +The `singleton` static method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return it in the case it does not. + +##### `rebuild_role_hierarchy_cache(self)` + +`rebuild_role_hierarchy` will rebuild the current role hierarchy that is stored in the `RoleHierarchy` table. This speeds up the querying of parent roles when assembling a users set of roles. This method is called for you automatically during `save`. + +#### `Resource` + +#### `RoleHierarchy` + +#### `RolePermission` + +### Fields + +#### `ImplicitRoleField` `ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your `Resource`. Configuring the role is done using some keyword arguments that are provided during declaration. @@ -65,15 +83,15 @@ The RBAC system defines a few new models. These models represent the underlying {'read':True} ``` -### `ImplicitResourceField` +#### `ImplicitResourceField` -## Mixins +### Mixins -### `ResourceMixin` +#### `ResourceMixin` By mixing in the `ResourceMixin` to your model, you are turning your model in to a `Resource` in the eyes of the RBAC implementation. What this means simply is that your model will now have an `ImplicitResourceField` named resource. Your model will also gain some methods that aid in the checking the access a users roles provides them to a resource. -#### `accessible_objects(cls, user, permissions)` +##### `accessible_objects(cls, user, permissions)` `accessible_objects` is a class method to use instead of `Model.objects`. This method will restrict the query of objects to only the objects that a user has the passed in permissions for. This is useful when you want to only filter and display a `Resource` that a users role grants them the `permissions` to. Note that any permission fields that are left blank will default to `False`. `accessible_objects` will only filter out resources where the expected permission was `True` but was returned as `False`. @@ -82,7 +100,7 @@ By mixing in the `ResourceMixin` to your model, you are turning your model in to objects.filter(name__istartswith='december') ``` -#### `get_permissions(self, user)` +##### `get_permissions(self, user)` `get_permissions` is an instance method that will give you the permission dictionary for a given user. This permission dictionary will take in to account any parent roles the user is apart of. @@ -93,7 +111,7 @@ By mixing in the `ResourceMixin` to your model, you are turning your model in to ``` -#### `accessible_by(self, user, permissions)` +##### `accessible_by(self, user, permissions)` `accessible_by` is an instance method that wraps the `get_permissions` method. Given a user and a dictionary of permissions this method will return True or False if a users roles give them a set of permissions that match the provided permissions dict. Not that any permission fields left blank will default to `False`. `accessible_by` will only return `False` in a case where the passed in permission is expected to be `True` but was returned as `False`. From 319252f555ea0514ba024ddc0daec89ccae50390 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 12 Feb 2016 10:16:29 -0500 Subject: [PATCH 055/297] Finish removing our raw SQL implemenations from our mixins Boiled out our current-best ORM implemenations. These can likely be optimized further, but are adequate for the time being. --- awx/main/models/mixins.py | 188 +++++--------------------------------- 1 file changed, 25 insertions(+), 163 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 3b7b5eb8d4..effdc7d436 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -1,11 +1,10 @@ # Django from django.db import models -from django.db import connection from django.db.models.aggregates import Max from django.contrib.contenttypes.models import ContentType # AWX -from awx.main.models.rbac import RolePermission, Role, Resource +from awx.main.models.rbac import Resource from awx.main.fields import ImplicitResourceField @@ -32,99 +31,15 @@ class ResourceMixin(models.Model): `myresource.get_permissions(user)`. ''' - # TODO: Clean this up once we have settled on an optimal implementation + qs = Resource.objects.filter( + content_type=ContentType.objects.get_for_model(cls), + permissions__role__ancestors__members=user + ) + for perm in permissions: + qs = qs.annotate(**{'max_' + perm: Max('permissions__' + perm)}) + qs = qs.filter(**{'max_' + perm: int(permissions[perm])}) - if False: - # This query does not work, but it is not apparent to me at this - # time why it does not. The intent is to be able to return a query - # set that does not involve a subselect, with the hope that this - # will perform better than our subselect method. I'm leaving it - # here for now so that I can revisit it with fresh eyes and - # hopefully find the issue. - # - # If someone else comes along and fixes or eliminates this, please - # tag me or let me know! - anoek 2016-02-11 - - qs = cls.objects - for perm in permissions: - kw = {'max_' + perm: Max('resource__permissions__' + perm)} - qs = qs.annotate(**kw) - kw = {'max_' + perm: int(permissions[perm])} - qs = qs.filter(**kw) - qs = qs.filter(resource__permissions__role__ancestors__members=user) - return qs - - if True: - # Begin working code.. this results in a subselect which I'm not too - # thrilled about, but performs reasonably ok - - qs = Resource.objects.filter( - content_type=ContentType.objects.get_for_model(cls), - permissions__role__ancestors__members=user - ) - for perm in permissions: - kw = {'max_' + perm: Max('permissions__' + perm)} - qs = qs.annotate(**kw) - kw = {'max_' + perm: int(permissions[perm])} - qs = qs.filter(**kw) - - return cls.objects.filter(resource__in=qs) - - if False: - # This works and remains the most performant implementation. Keeping it here - # until we can dethrone it with a proper ORM implementation - - aggregate_where_clause = '' - aggregates = '' - group_clause = '' - where_clause = '' - - if len(permissions) > 1: - group_clause = 'GROUP BY %s.resource_id' % RolePermission._meta.db_table - for perm in permissions: - if not aggregate_where_clause: - aggregate_where_clause = 'WHERE ' - else: - aggregate_where_clause += ' AND ' - aggregate_where_clause += '"%s" = %d' % (perm, int(permissions[perm])) - aggregates += ', MAX("%s") as "%s"' % (perm, perm) - if len(permissions) == 1: - perm = list(permissions.keys())[0] - where_clause = 'AND "%s" = %d' % (perm, int(permissions[perm])) - - return cls.objects.extra( - where=[ - ''' - %(table_name)s.resource_id in ( - SELECT resource_id FROM ( - SELECT %(rbac_permission)s.resource_id %(aggregates)s - FROM %(rbac_role)s_members - LEFT JOIN %(rbac_role_hierachy)s - ON (%(rbac_role_hierachy)s.to_role_id = %(rbac_role)s_members.role_id) - LEFT JOIN %(rbac_permission)s - ON (%(rbac_permission)s.role_id = %(rbac_role_hierachy)s.from_role_id) - WHERE %(rbac_role)s_members.user_id=%(user_id)d - %(where_clause)s - %(group_clause)s - order by resource_id - ) summarized_permissions - %(aggregate_where_clause)s - ) - ''' - % - { - 'table_name' : cls._meta.db_table, - 'aggregates' : aggregates, - 'user_id' : user.id, - 'aggregate_where_clause' : aggregate_where_clause, - 'group_clause' : group_clause, - 'where_clause' : where_clause, - 'rbac_role' : Role._meta.db_table, - 'rbac_permission' : RolePermission._meta.db_table, - 'rbac_role_hierachy' : 'main_rbac_roles_ancestors' - } - ] - ) + return cls.objects.filter(resource__in=qs) def get_permissions(self, user): @@ -142,78 +57,25 @@ class ResourceMixin(models.Model): access. ''' - # TODO: Clean this up once we have settled on an optimal implementation + qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self.resource) - if True: - # This works well enough at scale, but is about 5x slower than the - # raw sql variant, further optimization is desired. + qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) + qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) + qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) + qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) + qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) + qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) + qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) + qs = qs.annotate(max_use = Max('roles__descendents__permissions__use')) - qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self.resource) + qs = qs.values('max_create', 'max_read', 'max_write', 'max_update', + 'max_delete', 'max_scm_update', 'max_execute', 'max_use') - qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) - qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) - qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) - qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) - qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) - qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) - qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) - qs = qs.annotate(max_use = Max('roles__descendents__permissions__use')) - - qs = qs.values('max_create', 'max_read', 'max_write', 'max_update', - 'max_delete', 'max_scm_update', 'max_execute', 'max_use') - - #print('###############') - #print(qs.query) - #print('###############') - - res = qs.all() - if len(res): - return {k[4:]:v for k,v in res[0].items()} - return None - - - if False: - # This works and remains the most performant implementation. Keeping it here - # until we can dethrone it with a proper ORM implementation - - with connection.cursor() as cursor: - cursor.execute( - ''' - SELECT - MAX("create") as "create", - MAX("read") as "read", - MAX("write") as "write", - MAX("update") as "update", - MAX("delete") as "delete", - MAX("scm_update") as "scm_update", - MAX("execute") as "execute", - MAX("use") as "use" - - FROM %(rbac_permission)s - LEFT JOIN main_rbac_roles_ancestors - ON (%(rbac_permission)s.role_id = main_rbac_roles_ancestors.from_role_id) - INNER JOIN %(rbac_role)s_members - ON ( - %(rbac_role)s_members.role_id = main_rbac_roles_ancestors.to_role_id - AND %(rbac_role)s_members.user_id = %(user_id)d - ) - - WHERE %(rbac_permission)s.resource_id=%(resource_id)s - GROUP BY %(rbac_role)s_members.user_id - ''' - % - { - 'user_id': user.id, - 'resource_id': self.resource.id, - 'rbac_role': Role._meta.db_table, - 'rbac_permission': RolePermission._meta.db_table, - #'rbac_role_hierachy': RoleHierarchy._meta.db_table - } - ) - row = cursor.fetchone() - if row: - return dict(zip([x[0] for x in cursor.description], row)) - return None + res = qs.all() + if len(res): + # strip away the 'max_' prefix + return {k[4:]:v for k,v in res[0].items()} + return None def accessible_by(self, user, permissions): From 91c21c6c5b17175cbc11837375f861355b74a887 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 12 Feb 2016 10:17:15 -0500 Subject: [PATCH 056/297] Update rbac.md More documentation changes. --- docs/rbac.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index f1877b7c1c..b14135ab84 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -38,7 +38,7 @@ The RBAC system defines a few new models. These models represent the underlying #### `Role` -`Role` defines a single role within the RBAC implementation. It encapsulates the `parents` and `members` for a role. This model is intentially kepts dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. +`Role` defines a single role within the RBAC implementation. It encapsulates the `ancestors`, `parents`, and `members` for a role. This model is intentially kepts dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. ##### `grant(self, resource, permissions)` @@ -48,16 +48,18 @@ The `grant` instance method takes a resource and a set of permissions (see below The `singleton` static method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return it in the case it does not. -##### `rebuild_role_hierarchy_cache(self)` +##### `rebuild_role_ancestor_list(self)` -`rebuild_role_hierarchy` will rebuild the current role hierarchy that is stored in the `RoleHierarchy` table. This speeds up the querying of parent roles when assembling a users set of roles. This method is called for you automatically during `save`. +`rebuild_role_ancestor_list` will rebuild the current role ancestory that is stored in the `ancestor` field of a `Role`. This is called for you by `save` and different Django signals. #### `Resource` -#### `RoleHierarchy` +`Resource` is simply a method to associate many different objects (that may share PK/unique names) with a single type and ensures that those are unique with respect to the RBAC implementaion. Any Django model can be a resource in the RBAC implmentation by adding a `resource` field of type `Resource`, but in most cases it is reccomended to use the `ResourceMixin` which handles this for you. #### `RolePermission` +`RolePermission` holds a `role` and a `resource` and the permissions for that unique set. You interact with this model indirectly by using the `Role.grant` method and should never need to directly use this model unless you are extending the RBAC implementation itself. + ### Fields #### `ImplicitRoleField` @@ -65,8 +67,6 @@ The `singleton` static method is a helper method on the `Role` model that helps `ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your `Resource`. Configuring the role is done using some keyword arguments that are provided during declaration. -`resource_field` is the name of the field in your model that is a `ForeignKey` to a `Resource`. If you use the 'ResourceMixin', this field is added to your model for you and is called `resource`. This field is required for the RBAC implementation to integrate any of the role fields you declare for your model. If you did not use the `ResourceMixin` and you have manually added a `Resource` link to your model you will need to set this field accordingly. - `parent_role` is the link to any parent roles you want considered when a user is requesting access to your `Resource`. A `parent_role` can be declared as a single string, `parent.readonly`, or a list of many roles, `['parentA.readonly', 'parentB.readonly']`. It is important to note that a user does not need a parent role to access a resource if granted the role for that resource explicitly. Also a user will not have access to any parent resources by being granted a role for a child resource. We demonstrate this in the _Usage_ section of this document. `role_name` is the display name of the role. This is useful when generating reports or looking the results of queries. @@ -85,6 +85,8 @@ The `singleton` static method is a helper method on the `Role` model that helps #### `ImplicitResourceField` +The `ImplicitResourceField` is used by the `ResourceMixin` to give your model a `ForeignKey` to a `Resource`. If you use the mixin you will never need to declare this field explicitly for your model. + ### Mixins #### `ResourceMixin` From 104851e9a5a898abfe28a9ad5177024c066ed448 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 12 Feb 2016 10:18:12 -0500 Subject: [PATCH 057/297] Update rbac.md Remove resource_field --- docs/rbac.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index b14135ab84..b34b86a304 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -132,7 +132,6 @@ After exploring the _Overview_ the usage of the RBAC implementation in your code # declare your new role readonly_role = ImplicitRoleField( role_name="readonly", - resource_field="resource", permissions={'read':True}, ) ``` @@ -151,5 +150,3 @@ Now that your model is a `Resource` and has a `Role` defined, you can begin to a >>> document.accessible_by(user, {'read':True, 'write':True}) False # my role does not have write permission ``` - - From 76c2454936ba8b6821f7171a6180a0a7b3166e43 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 12 Feb 2016 10:44:53 -0500 Subject: [PATCH 058/297] Docs: Added RBAC basic concepts section --- docs/rbac.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index b34b86a304..86743cd363 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -5,14 +5,44 @@ The intended audience of this document is the Ansible Tower developer. ## Overview -The RBAC system allows you to create and layer roles for controlling access to resources. Any django Model can +### Role Based Access Control System Basics + +With Role Based Access Control Systems there are four main concepts to be +familiar with, Roles, Resources, Users, and Permissions. Users can be members +of a role, which gives them access to any permissions bestowed upon that Role. +In order to access a Resource, a Permission must be granted to a Role enabling +all members of that Role to access the Resource. + +For example, if I have an organization named "MyCompany" and I want to allow +two people, "Alice", and "Bob", access to manage all the settings associated +with that organization, I'd create a role (maybe called "MyCompany +Administrator"), create a Permission to edit the organization "MyCompany" and +assign it to the "MyCompany Administrator" role. I'd also add the two users +"Alice" and "Bob" as members of the Role. + +It is often the case that you have many Roles in a system, and you want some +roles to include all of the permissions of other roles. For example, you may +want a System Administrator to have access to everything that an Organization +Administrator has access to, who has everything that a Project Administrator +has access to, and so on. We refer to this concept as the 'Role Hierarchy', and +is represented by allowing Roles to have "Parent Roles". Any permission that a +Role has is implicitly granted to any parent roles (or parents of those +parents, and so on). Of course Roles can have more than one parent, and +permissions are implicitly granted to all parents. (Technically speaking, this +forms a directional graph instead of a hierarchy, but the concept should remain +intuitive.) + + +### Implementation Overview + +The RBAC system allows you to create and layer roles for controlling access to resources. Any Django Model can be made into a `Resource` in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource you can extend the model definition to have specific roles using the `ImplicitRoleField`. This role field allows you to configure the name of a role, any parents a role may have, and the permissions having this role will grant you to the resource. ### Roles -Roles are defined for a resource. If a role has any parents, these parents will be considered when determing +Roles are defined for a resource. If a role has any parents, these parents will be considered when determining what roles are checked when accessing a resource. ResourceA @@ -34,11 +64,11 @@ There is a special case _Singleton Role_ that you can create. This type of role ### Models -The RBAC system defines a few new models. These models represent the underlying RBAC implemnentation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins. +The RBAC system defines a few new models. These models represent the underlying RBAC implementation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins. #### `Role` -`Role` defines a single role within the RBAC implementation. It encapsulates the `ancestors`, `parents`, and `members` for a role. This model is intentially kepts dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. +`Role` defines a single role within the RBAC implementation. It encapsulates the `ancestors`, `parents`, and `members` for a role. This model is intentionally kept dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles. ##### `grant(self, resource, permissions)` @@ -54,7 +84,7 @@ The `singleton` static method is a helper method on the `Role` model that helps #### `Resource` -`Resource` is simply a method to associate many different objects (that may share PK/unique names) with a single type and ensures that those are unique with respect to the RBAC implementaion. Any Django model can be a resource in the RBAC implmentation by adding a `resource` field of type `Resource`, but in most cases it is reccomended to use the `ResourceMixin` which handles this for you. +`Resource` is simply a method to associate many different objects (that may share PK/unique names) with a single type and ensures that those are unique with respect to the RBAC implementation. Any Django model can be a resource in the RBAC implementation by adding a `resource` field of type `Resource`, but in most cases it is recommended to use the `ResourceMixin` which handles this for you. #### `RolePermission` @@ -64,7 +94,7 @@ The `singleton` static method is a helper method on the `Role` model that helps #### `ImplicitRoleField` -`ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your +`ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your `Resource`. Configuring the role is done using some keyword arguments that are provided during declaration. `parent_role` is the link to any parent roles you want considered when a user is requesting access to your `Resource`. A `parent_role` can be declared as a single string, `parent.readonly`, or a list of many roles, `['parentA.readonly', 'parentB.readonly']`. It is important to note that a user does not need a parent role to access a resource if granted the role for that resource explicitly. Also a user will not have access to any parent resources by being granted a role for a child resource. We demonstrate this in the _Usage_ section of this document. From a4c435c14ec45a702a13ceba9270012c7ec5c74d Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 12 Feb 2016 11:02:12 -0500 Subject: [PATCH 059/297] doc: Added an example RBAC picture --- docs/img/rbac_example.svg | 1 + docs/rbac.md | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 docs/img/rbac_example.svg diff --git a/docs/img/rbac_example.svg b/docs/img/rbac_example.svg new file mode 100644 index 0000000000..6c79d54336 --- /dev/null +++ b/docs/img/rbac_example.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/rbac.md b/docs/rbac.md index 86743cd363..18fa6223df 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -32,6 +32,8 @@ permissions are implicitly granted to all parents. (Technically speaking, this forms a directional graph instead of a hierarchy, but the concept should remain intuitive.) +![Example RBAC hierarchy](img/rbac_example.svg) + ### Implementation Overview From 0ef004171d040c1cdfcc84107cdb6a26693db8a0 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 12 Feb 2016 11:07:31 -0500 Subject: [PATCH 060/297] doc: Try PNG for github markdown instead of SVG --- docs/img/rbac_example.png | Bin 0 -> 48391 bytes docs/rbac.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/img/rbac_example.png diff --git a/docs/img/rbac_example.png b/docs/img/rbac_example.png new file mode 100644 index 0000000000000000000000000000000000000000..5b555bca41f0022e606e8f0b213c0a7aecdac61a GIT binary patch literal 48391 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 Ho%{a}Iv77w literal 0 HcmV?d00001 diff --git a/docs/rbac.md b/docs/rbac.md index 18fa6223df..2b0304dd5d 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -32,7 +32,7 @@ permissions are implicitly granted to all parents. (Technically speaking, this forms a directional graph instead of a hierarchy, but the concept should remain intuitive.) -![Example RBAC hierarchy](img/rbac_example.svg) +![Example RBAC hierarchy](img/rbac_example.png?raw=true) ### Implementation Overview From 409c7baa338b7660dd9d3ef9dfc57198820ce717 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 12 Feb 2016 11:08:08 -0500 Subject: [PATCH 061/297] doc: Removing .svg for our example image Apparently .svg's don't work in github markdown --- docs/img/rbac_example.svg | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/img/rbac_example.svg diff --git a/docs/img/rbac_example.svg b/docs/img/rbac_example.svg deleted file mode 100644 index 6c79d54336..0000000000 --- a/docs/img/rbac_example.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 61679a47acf19e7ec8eb3368eadb0925defc57e6 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 12 Feb 2016 11:42:00 -0500 Subject: [PATCH 062/297] Update rbac.md --- docs/rbac.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/rbac.md b/docs/rbac.md index 2b0304dd5d..32ed53ad0f 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -40,7 +40,7 @@ intuitive.) The RBAC system allows you to create and layer roles for controlling access to resources. Any Django Model can be made into a `Resource` in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource you can extend the model definition to have specific roles using the `ImplicitRoleField`. This role field allows you to -configure the name of a role, any parents a role may have, and the permissions having this role will grant you to the resource. +configure the name of a role, any parents a role may have, and the permissions this role will grant to members. ### Roles @@ -54,11 +54,11 @@ what roles are checked when accessing a resource. | -- AdminRole |-- parent = ResourceA.AdminRole -When a user attempts to access ResourceB we will check for their access using the set of all unique roles, include the parents. +When a user attempts to access ResourceB we will check for their access using the set of all unique roles, including the parents. - set: ResourceA.AdminRole, ResourceB.AdminRole + ResourceA.AdminRole, ResourceB.AdminRole -This would provide anyone with the above roles access to ResourceB. +This would provide any members of the above roles with access to ResourceB. #### Singleton Role @@ -74,29 +74,29 @@ The RBAC system defines a few new models. These models represent the underlying ##### `grant(self, resource, permissions)` -The `grant` instance method takes a resource and a set of permissions (see below) and creates an entry in the `RolePermission` table (described below). The result of this being that any member of this role will now have those granted permissions to the resource. The `grant` method considers a resource to be anything that is explicitly of the `Resource` type or any model that has a `resource` field that is of type `Resource`. +The `grant` instance method takes a resource and a set of permissions (see below) and creates an entry in the `RolePermission` table (described below). The result of this being that any member of this role will now have those permissions to the resource. The `grant` method considers a resource to be anything that is explicitly of type `Resource` or any model that has a `resource` field of type `Resource`. ##### `singleton(name)` -The `singleton` static method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return it in the case it does not. +The `singleton` static method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return the new role in the case it does not. ##### `rebuild_role_ancestor_list(self)` -`rebuild_role_ancestor_list` will rebuild the current role ancestory that is stored in the `ancestor` field of a `Role`. This is called for you by `save` and different Django signals. +`rebuild_role_ancestor_list` will rebuild the current role ancestory that is stored in the `ancestors` field of a `Role`. This is called for you by `save` and different Django signals. #### `Resource` -`Resource` is simply a method to associate many different objects (that may share PK/unique names) with a single type and ensures that those are unique with respect to the RBAC implementation. Any Django model can be a resource in the RBAC implementation by adding a `resource` field of type `Resource`, but in most cases it is recommended to use the `ResourceMixin` which handles this for you. +`Resource` is simply a method to associate many different objects (that may share PK/unique names) with a single type. The `Resource` type ensure the objunique with respect to the RBAC implementation. Any Django model can be a resource in the RBAC implementation by adding a `resource` field of type `Resource`, but in most cases it is recommended to use the `ResourceMixin` which handles this for you. #### `RolePermission` -`RolePermission` holds a `role` and a `resource` and the permissions for that unique set. You interact with this model indirectly by using the `Role.grant` method and should never need to directly use this model unless you are extending the RBAC implementation itself. +`RolePermission` holds a `role` and a `resource` and the permissions for that unique set. You interact with this model indirectly when declaring `ImplicitRoleField` fields and also when you use the `Role.grant` method. Generally you will not directly use this model unless you are extending the RBAC implementation itself. ### Fields #### `ImplicitRoleField` -`ImplicitRoleField` role fields are defined on your model. They provide the definition of grantable roles for accessing your +`ImplicitRoleField` fields are declared on your model. They provide the definition of grantable roles for accessing your `Resource`. Configuring the role is done using some keyword arguments that are provided during declaration. `parent_role` is the link to any parent roles you want considered when a user is requesting access to your `Resource`. A `parent_role` can be declared as a single string, `parent.readonly`, or a list of many roles, `['parentA.readonly', 'parentB.readonly']`. It is important to note that a user does not need a parent role to access a resource if granted the role for that resource explicitly. Also a user will not have access to any parent resources by being granted a role for a child resource. We demonstrate this in the _Usage_ section of this document. From e0371f374540f3b4cafb1e186ea3313fc23320af Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 15 Feb 2016 10:43:50 -0500 Subject: [PATCH 063/297] 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 064/297] 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 065/297] 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 066/297] 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 067/297] 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 From 7260c1cc0ec8083dfe384fa1d2e6a8d724ce8348 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 15 Feb 2016 16:32:42 -0500 Subject: [PATCH 068/297] Added credential access tests --- .../tests/functional/test_rbac_credential.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 9de46f8115..fe115c550e 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -1,7 +1,10 @@ import pytest +from awx.main.access import CredentialAccess +from awx.main.models.credential import Credential from awx.main.migrations import _rbac as rbac from django.apps import apps +from django.contrib.auth.models import User @pytest.mark.django_db def test_credential_migration_user(credential, user, permissions): @@ -51,3 +54,35 @@ def test_credential_migration_team_admin(credential, team, user, permissions): assert len(migrated) == 1 assert credential.accessible_by(u, permissions['usage']) +def test_credential_access_superuser(): + u = User(username='admin', is_superuser=True) + access = CredentialAccess(u) + credential = Credential() + + assert access.can_add(None) + assert access.can_change(credential, None) + assert access.can_delete(credential) + +@pytest.mark.django_db +def test_credential_access_admin(user, organization, team, credential): + u = user('org-admin', False) + organization.admins.add(u) + team.organization = organization + team.save() + + access = CredentialAccess(u) + + assert access.can_add({'user': u.pk}) + assert access.can_add({'team': team.pk}) + + assert not access.can_change(credential, {'user': u.pk}) + + # unowned credential can be deleted + assert access.can_delete(credential) + + credential.created_by = u + credential.save() + assert not access.can_change(credential, {'user': u.pk}) + + team.users.add(u) + assert access.can_change(credential, {'user': u.pk}) From 017a7ee060fd8ce74712fe85d39ff126210dc41e Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 16 Feb 2016 08:53:35 -0500 Subject: [PATCH 069/297] Updating RBAC migrations --- awx/main/migrations/0003_rbac_changes.py | 37 +++++++++--------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/awx/main/migrations/0003_rbac_changes.py b/awx/main/migrations/0003_rbac_changes.py index 0b9de6c100..23aee5f92d 100644 --- a/awx/main/migrations/0003_rbac_changes.py +++ b/awx/main/migrations/0003_rbac_changes.py @@ -12,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('taggit', '0002_auto_20150616_2121'), + ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('main', '0002_v300_changes'), ] @@ -26,9 +27,10 @@ class Migration(migrations.Migration): ('description', models.TextField(default=b'', blank=True)), ('active', models.BooleanField(default=True, editable=False)), ('name', models.CharField(max_length=512)), + ('object_id', models.PositiveIntegerField(default=None, null=True)), + ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType', null=True)), ('created_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), ('modified_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('parent', models.ForeignKey(related_name='children', default=None, to='main.Resource', null=True)), ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), ], options={ @@ -46,6 +48,9 @@ class Migration(migrations.Migration): ('active', models.BooleanField(default=True, editable=False)), ('name', models.CharField(max_length=512)), ('singleton_name', models.TextField(default=None, unique=True, null=True, db_index=True)), + ('object_id', models.PositiveIntegerField(default=None, null=True)), + ('ancestors', models.ManyToManyField(related_name='descendents', to='main.Role')), + ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType', null=True)), ('created_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), ('members', models.ManyToManyField(related_name='roles', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(related_name="{u'class': 'role', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), @@ -57,20 +62,6 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'roles', }, ), - migrations.CreateModel( - name='RoleHierarchy', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('ancestor', models.ForeignKey(related_name='+', to='main.Role')), - ('role', models.ForeignKey(related_name='+', to='main.Role')), - ], - options={ - 'db_table': 'main_rbac_role_hierarchy', - 'verbose_name_plural': 'role_ancestors', - }, - ), migrations.CreateModel( name='RolePermission', fields=[ @@ -93,10 +84,10 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'permissions', }, ), - migrations.AddField( - model_name='project', - name='organization', - field=models.ForeignKey(related_name='project_list', on_delete=django.db.models.deletion.SET_NULL, to='main.Organization', null=True), + migrations.AlterField( + model_name='towersettings', + name='value', + field=models.TextField(blank=True), ), migrations.AddField( model_name='credential', @@ -205,13 +196,13 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='organization', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), + name='member_role', + field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), migrations.AddField( model_name='organization', - name='member_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), + name='resource', + field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), ), migrations.AddField( model_name='project', From 40ec6a2da2d1e6aab0c00e9eae8151880ea75781 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 16 Feb 2016 10:59:53 -0500 Subject: [PATCH 070/297] Fix rbac cred tests --- awx/main/tests/functional/test_rbac_credential.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index fe115c550e..2e44442528 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -80,9 +80,10 @@ def test_credential_access_admin(user, organization, team, credential): # unowned credential can be deleted assert access.can_delete(credential) - credential.created_by = u - credential.save() + team.users.add(u) assert not access.can_change(credential, {'user': u.pk}) - team.users.add(u) + credential.team = team + credential.save() + assert access.can_change(credential, {'user': u.pk}) From 5306eaa98ce4fa96ce9ce8e5b051b7ddfa123abb Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 16 Feb 2016 21:04:33 -0500 Subject: [PATCH 071/297] more access tests and a Makefile driveby --- Makefile | 2 +- .../functional/test_rbac_job_templates.py | 18 ++++++++++++++++++ .../tests/functional/test_rbac_organization.py | 17 +++++++++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 90db6a796c..3da54a86cd 100644 --- a/Makefile +++ b/Makefile @@ -798,7 +798,7 @@ docker-compose-test: MACHINE?=default docker-refresh: - rm -f awx/lib/.deps_built + rm -f awx/lib/.deps_built awx/lib/site-packages eval $$(docker-machine env $(MACHINE)) docker stop $$(docker ps -a -q) docker rm $$(docker ps -f name=tools_tower -a -q) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index 8792a1b8f4..081a9ec8f8 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -1,9 +1,15 @@ +import mock import pytest +from awx.main.access import ( + BaseAccess, + JobTemplateAccess, +) 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) @@ -131,3 +137,15 @@ def test_job_template_team_deploy_migration(deploy_jobtemplate, check_jobtemplat assert check_jobtemplate.accessible_by(admin, {'execute': True}) is True assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + +@mock.patch.object(BaseAccess, 'check_license', return_value=None) +@pytest.mark.django_db +def test_job_template_access_superuser(check_license, user, deploy_jobtemplate): + # GIVEN a superuser + u = user('admin', True) + # WHEN access to a job template is checked + access = JobTemplateAccess(u) + # THEN all access checks should pass + assert access.can_read(deploy_jobtemplate) + assert access.can_add({}) diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index b9ba96c4c6..39e21f36e3 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -1,7 +1,11 @@ +import mock import pytest from awx.main.migrations import _rbac as rbac -from awx.main.access import OrganizationAccess +from awx.main.access import ( + BaseAccess, + OrganizationAccess, +) from django.apps import apps @@ -29,8 +33,10 @@ def test_organization_migration_user(organization, permissions, user): assert len(migrations) == 1 assert organization.accessible_by(u, permissions['auditor']) + +@mock.patch.object(BaseAccess, 'check_license', return_value=None) @pytest.mark.django_db -def test_organization_access_superuser(organization, user): +def test_organization_access_superuser(cl, organization, user): access = OrganizationAccess(user('admin', True)) organization.users.add(user('user', False)) @@ -42,8 +48,9 @@ def test_organization_access_superuser(organization, user): assert len(org.users.all()) == 1 +@mock.patch.object(BaseAccess, 'check_license', return_value=None) @pytest.mark.django_db -def test_organization_access_admin(organization, user): +def test_organization_access_admin(cl, organization, user): '''can_change because I am an admin of that org''' a = user('admin', False) organization.admins.add(a) @@ -57,8 +64,10 @@ def test_organization_access_admin(organization, user): assert len(org.admins.all()) == 1 assert len(org.users.all()) == 1 + +@mock.patch.object(BaseAccess, 'check_license', return_value=None) @pytest.mark.django_db -def test_organization_access_user(organization, user): +def test_organization_access_user(cl, organization, user): access = OrganizationAccess(user('user', False)) organization.users.add(user('user', False)) From 74e1554463dff29c46c299b2f480a259a5b94dd4 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 17 Feb 2016 11:59:06 -0500 Subject: [PATCH 072/297] Only touch the attribute if it does not exist to avoid recursion in activity streams --- awx/main/fields.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index df0da42538..8337423367 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -85,7 +85,8 @@ class ImplicitResourceField(models.ForeignKey): def _save(self, instance, *args, **kwargs): # Ensure that our field gets initialized after our first save - getattr(instance, self.name) + if not hasattr(instance, self.name): + getattr(instance, self.name) class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): @@ -257,4 +258,5 @@ class ImplicitRoleField(models.ForeignKey): def _save(self, instance, *args, **kwargs): # Ensure that our field gets initialized after our first save - getattr(instance, self.name) + if not hasattr(instance, self.name): + getattr(instance, self.name) From 30f88b6e3097bdaea4de339dd8d633dd89f42064 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 18 Feb 2016 09:51:32 -0500 Subject: [PATCH 073/297] Added redis in-memory fixture for functional tests --- awx/main/tests/functional/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index ad8c994a7d..7305b95799 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -130,3 +130,8 @@ def get(): middleware.process_response(request, response) return response return rf + +@pytest.fixture(scope="session", autouse=True) +def celery_memory_broker(): + from django.conf import settings + settings.BROKER_URL='memory://localhost/' From 65c20e9de26df4a7fbc8a0ec5b3c0e39a69a25bb Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 18 Feb 2016 13:36:36 -0500 Subject: [PATCH 074/297] use objects instead of _default_manager --- awx/main/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 8337423367..e5f6e5d0f8 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -63,7 +63,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) if resource: return resource - resource = Resource._default_manager.create(content_object=instance) + resource = Resource.objects.create(content_object=instance) setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) return resource @@ -107,7 +107,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if not self.role_name: raise FieldError('Implicit role missing `role_name`') - role = Role._default_manager.create(name=self.role_name, content_object=instance) + role = Role.objects.create(name=self.role_name, content_object=instance) if self.parent_role: def resolve_field(obj, field): ret = [] From fb5369404d61e49db8590c805f0f220357a22700 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 18 Feb 2016 15:33:27 -0500 Subject: [PATCH 075/297] (HACK) bump values while we investigate slowness --- awx/main/tests/old/commands/commands_monolithic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/main/tests/old/commands/commands_monolithic.py b/awx/main/tests/old/commands/commands_monolithic.py index d5e00818ca..c658f6411c 100644 --- a/awx/main/tests/old/commands/commands_monolithic.py +++ b/awx/main/tests/old/commands/commands_monolithic.py @@ -1063,7 +1063,7 @@ class InventoryImportTest(BaseCommandMixin, BaseLiveServerTest): self.assertNotEqual(new_inv.groups.count(), 0) self.assertNotEqual(new_inv.total_hosts, 0) self.assertNotEqual(new_inv.total_groups, 0) - self.assertElapsedLessThan(30) + self.assertElapsedLessThan(60) def test_splunk_inventory(self): new_inv = self.organizations[0].inventories.create(name='splunk') @@ -1082,7 +1082,7 @@ class InventoryImportTest(BaseCommandMixin, BaseLiveServerTest): self.assertNotEqual(new_inv.groups.count(), 0) self.assertNotEqual(new_inv.total_hosts, 0) self.assertNotEqual(new_inv.total_groups, 0) - self.assertElapsedLessThan(120) + self.assertElapsedLessThan(600) def _get_ngroups_for_nhosts(self, n): if n > 0: @@ -1108,7 +1108,7 @@ class InventoryImportTest(BaseCommandMixin, BaseLiveServerTest): self.assertEqual(new_inv.hosts.filter(active=False).count(), nhosts_inactive) self.assertEqual(new_inv.total_hosts, nhosts) self.assertEqual(new_inv.total_groups, ngroups) - self.assertElapsedLessThan(45) + self.assertElapsedLessThan(120) @unittest.skipIf(getattr(settings, 'LOCAL_DEVELOPMENT', False), 'Skip this test in local development environments, ' From fa1137d3718e7eff240bd65cb4fde523ab65f4d9 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Thu, 18 Feb 2016 18:16:19 -0500 Subject: [PATCH 076/297] Use TransactionTestCase with Django 1.8+ --- awx/main/tests/old/jobs/job_launch.py | 4 ++-- awx/main/tests/old/jobs/jobs_monolithic.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/main/tests/old/jobs/job_launch.py b/awx/main/tests/old/jobs/job_launch.py index 4d1899de09..0beb3a9546 100644 --- a/awx/main/tests/old/jobs/job_launch.py +++ b/awx/main/tests/old/jobs/job_launch.py @@ -15,7 +15,7 @@ import yaml __all__ = ['JobTemplateLaunchTest', 'JobTemplateLaunchPasswordsTest'] -class JobTemplateLaunchTest(BaseJobTestMixin, django.test.TestCase): +class JobTemplateLaunchTest(BaseJobTestMixin, django.test.TransactionTestCase): def setUp(self): super(JobTemplateLaunchTest, self).setUp() @@ -178,7 +178,7 @@ class JobTemplateLaunchTest(BaseJobTestMixin, django.test.TestCase): with self.current_user(self.user_sue): self.post(self.launch_url, {}, expect=400) -class JobTemplateLaunchPasswordsTest(BaseJobTestMixin, django.test.TestCase): +class JobTemplateLaunchPasswordsTest(BaseJobTestMixin, django.test.TransactionTestCase): def setUp(self): super(JobTemplateLaunchPasswordsTest, self).setUp() diff --git a/awx/main/tests/old/jobs/jobs_monolithic.py b/awx/main/tests/old/jobs/jobs_monolithic.py index 1d36972245..0e16afe0b2 100644 --- a/awx/main/tests/old/jobs/jobs_monolithic.py +++ b/awx/main/tests/old/jobs/jobs_monolithic.py @@ -183,7 +183,7 @@ TEST_SURVEY_REQUIREMENTS = ''' } ''' -class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): +class JobTemplateTest(BaseJobTestMixin, django.test.TransactionTestCase): JOB_TEMPLATE_FIELDS = ('id', 'type', 'url', 'related', 'summary_fields', 'created', 'modified', 'name', 'description', @@ -492,7 +492,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): with self.current_user(self.user_doug): self.get(detail_url, expect=403) -class JobTest(BaseJobTestMixin, django.test.TestCase): +class JobTest(BaseJobTestMixin, django.test.TransactionTestCase): def test_get_job_list(self): url = reverse('api:job_list') @@ -1068,7 +1068,7 @@ class JobTransactionTest(BaseJobTestMixin, django.test.LiveServerTestCase): self.assertEqual(job.status, 'successful', job.result_stdout) self.assertFalse(errors) -class JobTemplateSurveyTest(BaseJobTestMixin, django.test.TestCase): +class JobTemplateSurveyTest(BaseJobTestMixin, django.test.TransactionTestCase): def setUp(self): super(JobTemplateSurveyTest, self).setUp() # TODO: Test non-enterprise license From aa3a33447e6d6151cf08ccb83458fc407d9f5536 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 17 Feb 2016 15:33:19 -0500 Subject: [PATCH 077/297] Automatically add users with is_superuser to System Admin role Also fixed issue with System Admin role name not being set and made some constants for the singleton names we use --- awx/main/models/rbac.py | 7 +++++-- awx/main/signals.py | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 1a5c189892..de95f0e0af 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -13,10 +13,13 @@ from django.contrib.contenttypes.fields import GenericForeignKey # AWX from awx.main.models.base import * # noqa -__all__ = ['Role', 'RolePermission', 'Resource'] +__all__ = ['Role', 'RolePermission', 'Resource', 'ROLE_SINGLETON_SYSTEM_ADMINISTRATOR', 'ROLE_SINGLETON_SYSTEM_AUDITOR'] logger = logging.getLogger('awx.main.models.rbac') +ROLE_SINGLETON_SYSTEM_ADMINISTRATOR='System Administrator' +ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' + class Role(CommonModelNameNotUnique): ''' @@ -91,7 +94,7 @@ class Role(CommonModelNameNotUnique): try: return Role.objects.get(singleton_name=name) except Role.DoesNotExist: - ret = Role(singleton_name=name) + ret = Role(singleton_name=name, name=name) ret.save() return ret diff --git a/awx/main/signals.py b/awx/main/signals.py index f5778dbb2e..2e3e8b6c62 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -122,6 +122,12 @@ def rebuild_role_ancestor_list(sender, reverse, model, instance, pk_set, **kwarg else: instance.rebuild_role_ancestor_list() +def sync_superuser_status_to_rbac(sender, instance, **kwargs): + if instance.is_superuser: + Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.add(instance) + else: + Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.remove(instance) + pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -142,6 +148,7 @@ post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) +post_save.connect(sync_superuser_status_to_rbac, sender=User) #m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or From 80476cbb2a7541c1adf3457420786417bf05e017 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 19 Feb 2016 14:39:24 -0500 Subject: [PATCH 078/297] Automatically add/remove user to the member_role when a user is added to / removed from a team --- awx/main/signals.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/awx/main/signals.py b/awx/main/signals.py index 2e3e8b6c62..0067f6da0b 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -116,6 +116,7 @@ def store_initial_active_state(sender, **kwargs): instance._saved_active_state = True def rebuild_role_ancestor_list(sender, reverse, model, instance, pk_set, **kwargs): + 'When a role parent is added or removed, update our role hierarchy list' if reverse: for id in pk_set: model.objects.get(id=id).rebuild_role_ancestor_list() @@ -123,11 +124,28 @@ def rebuild_role_ancestor_list(sender, reverse, model, instance, pk_set, **kwarg instance.rebuild_role_ancestor_list() def sync_superuser_status_to_rbac(sender, instance, **kwargs): + 'When the is_superuser flag is changed on a user, reflect that in the membership of the System Admnistrator role' if instance.is_superuser: Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.add(instance) else: Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.remove(instance) +def sync_user_to_team_members_role(sender, reverse, model, instance, pk_set, action, **kwargs): + 'When a user is added or removed from Team.users, ensure that is reflected in Team.member_role' + if action == 'post_add' or action == 'pre_remove': + if reverse: + for team in Team.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + team.member_role.members.add(instance) + if action == 'pre_remove': + team.member_role.members.remove(instance) + else: + for user in User.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + instance.member_role.members.add(user) + if action == 'pre_remove': + instance.member_role.members.remove(user) + pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -149,6 +167,7 @@ post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) +m2m_changed.connect(sync_user_to_team_members_role, Team.users.through) #m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or From 1e1f49c3eb8f755efd4a574549bcef970193aa7a Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 19 Feb 2016 14:40:06 -0500 Subject: [PATCH 079/297] Initial unit tests for core rbac functionality --- awx/main/tests/functional/test_rbac_core.py | 103 ++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 awx/main/tests/functional/test_rbac_core.py diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py new file mode 100644 index 0000000000..b31ef310b0 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_core.py @@ -0,0 +1,103 @@ +import pytest + +from awx.main.models import ( + Role, + Organization, +) + + +@pytest.mark.django_db +def test_auto_inheritance_by_children(organization, alice): + A = Role.objects.create(name='A') + B = Role.objects.create(name='B') + A.members.add(alice) + + assert organization.accessible_by(alice, {'read': True}) is False + A.children.add(B) + assert organization.accessible_by(alice, {'read': True}) is False + A.children.add(organization.admin_role) + assert organization.accessible_by(alice, {'read': True}) is True + A.children.remove(organization.admin_role) + assert organization.accessible_by(alice, {'read': True}) is False + B.children.add(organization.admin_role) + assert organization.accessible_by(alice, {'read': True}) is True + B.children.remove(organization.admin_role) + assert organization.accessible_by(alice, {'read': True}) is False + + +@pytest.mark.django_db +def test_auto_inheritance_by_parents(organization, alice): + A = Role.objects.create(name='A') + B = Role.objects.create(name='B') + A.members.add(alice) + + assert organization.accessible_by(alice, {'read': True}) is False + B.parents.add(A) + assert organization.accessible_by(alice, {'read': True}) is False + organization.admin_role.parents.add(A) + assert organization.accessible_by(alice, {'read': True}) is True + organization.admin_role.parents.remove(A) + assert organization.accessible_by(alice, {'read': True}) is False + organization.admin_role.parents.add(B) + assert organization.accessible_by(alice, {'read': True}) is True + organization.admin_role.parents.remove(B) + assert organization.accessible_by(alice, {'read': True}) is False + + +@pytest.mark.django_db +def test_permission_union(organization, alice): + A = Role.objects.create(name='A') + A.members.add(alice) + B = Role.objects.create(name='B') + B.members.add(alice) + + assert organization.accessible_by(alice, {'read': True, 'write': True}) is False + A.grant(organization, {'read': True}) + assert organization.accessible_by(alice, {'read': True, 'write': True}) is False + B.grant(organization, {'write': True}) + assert organization.accessible_by(alice, {'read': True, 'write': True}) is True + + +@pytest.mark.django_db +def test_team_symantics(organization, team, alice): + assert organization.accessible_by(alice, {'read': True}) is False + team.member_role.children.add(organization.auditor_role) + assert organization.accessible_by(alice, {'read': True}) is False + team.users.add(alice) + assert organization.accessible_by(alice, {'read': True}) is True + team.users.remove(alice) + assert organization.accessible_by(alice, {'read': True}) is False + alice.teams.add(team) + assert organization.accessible_by(alice, {'read': True}) is True + alice.teams.remove(team) + assert organization.accessible_by(alice, {'read': True}) is False + + +@pytest.mark.django_db +def test_auto_m2m_adjuments(organization, project, alice): + 'Ensures the auto role reparenting is working correctly through m2m maps' + organization.admin_role.members.add(alice) + assert project.accessible_by(alice, {'read': True}) is True + + project.organizations.remove(organization) + assert project.accessible_by(alice, {'read': True}) is False + project.organizations.add(organization) + assert project.accessible_by(alice, {'read': True}) is True + + organization.projects.remove(project) + assert project.accessible_by(alice, {'read': True}) is False + organization.projects.add(project) + assert project.accessible_by(alice, {'read': True}) is True + +@pytest.mark.django_db +@pytest.mark.skipif(True, reason='Unimplemented') +def test_auto_field_adjuments(organization, inventory, team, alice): + 'Ensures the auto role reparenting is working correctly through m2m maps' + org2 = Organization.objects.create(name='Org 2', description='org 2') + org2.admin_role.members.add(alice) + assert inventory.accessible_by(alice, {'read': True}) is False + inventory.organization = org2 + assert inventory.accessible_by(alice, {'read': True}) is True + inventory.organization = organization + assert inventory.accessible_by(alice, {'read': True}) is False + From 0ff94e424dba127054cf949a3ddd914ae0452c03 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 11:07:32 -0500 Subject: [PATCH 080/297] SubList views can now resolve deep relationships using dot notation for relationship specification Made it so you can specify a relationship like 'parent.somelist' --- awx/api/generics.py | 8 ++++---- awx/main/utils.py | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index 6618263742..da9ea6dfa2 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -348,7 +348,7 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): # object deserialized obj = serializer.save() serializer = self.get_serializer(instance=obj) - + headers = {'Location': obj.get_absolute_url()} return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) @@ -359,7 +359,7 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): def attach(self, request, *args, **kwargs): created = False parent = self.get_parent_object() - relationship = getattr(parent, self.relationship) + relationship = getattrd(parent, self.relationship) sub_id = request.data.get('id', None) data = request.data @@ -378,7 +378,7 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): # Retrive the sub object (whether created or by ID). sub = get_object_or_400(self.model, pk=sub_id) - + # Verify we have permission to attach. if not request.user.can_access(self.parent_model, 'attach', parent, sub, self.relationship, data, @@ -405,7 +405,7 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView): parent = self.get_parent_object() parent_key = getattr(self, 'parent_key', None) - relationship = getattr(parent, self.relationship) + relationship = getattrd(parent, self.relationship) sub = get_object_or_400(self.model, pk=sub_id) if not request.user.can_access(self.parent_model, 'unattach', parent, diff --git a/awx/main/utils.py b/awx/main/utils.py index 5bd00c2da6..00bfc74608 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -30,7 +30,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'get_ansible_version', 'get_ssh_version', 'get_awx_version', 'update_scm_url', 'get_type_for_model', 'get_model_for_type', 'to_python_boolean', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', - '_inventory_updates', 'get_pk_from_dict'] + '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided'] def get_object_or_400(klass, *args, **kwargs): @@ -521,3 +521,21 @@ def timedelta_total_seconds(timedelta): timedelta.microseconds + 0.0 + (timedelta.seconds + timedelta.days * 24 * 3600) * 10 ** 6) / 10 ** 6 + +class NoDefaultProvided(object): + pass + +def getattrd(obj, name, default=NoDefaultProvided): + """ + Same as getattr(), but allows dot notation lookup + Discussed in: + http://stackoverflow.com/questions/11975781 + """ + + try: + return reduce(getattr, name.split("."), obj) + except AttributeError: + if default != NoDefaultProvided: + return default + raise + From 7d4b54a651f73bb86de009842052564fadc8efe6 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 14:52:57 -0500 Subject: [PATCH 081/297] Fixed __all__ def --- awx/main/models/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index effdc7d436..63ecf3a0dd 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -8,7 +8,7 @@ from awx.main.models.rbac import Resource from awx.main.fields import ImplicitResourceField -__all__ = 'ResourceMixin' +__all__ = ['ResourceMixin'] class ResourceMixin(models.Model): From 5071dba4ff010688e7264c3ac049d28a9417c938 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 14:54:27 -0500 Subject: [PATCH 082/297] Moved RBAC get_permissions implemenation to the Resource model I had need to perform this query right on a Resource, so I moved it from the mixin to the Resource --- awx/main/models/mixins.py | 35 +---------------------------------- awx/main/models/rbac.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 63ecf3a0dd..6d069ed3d4 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -43,40 +43,7 @@ class ResourceMixin(models.Model): def get_permissions(self, user): - ''' - Returns a dict (or None) of the permissions a user has for a given - resource. - - Note: Each field in the dict is the `or` of all respective permissions - that have been granted to the roles that are applicable for the given - user. - - In example, if a user has been granted read access through a permission - on one role and write access through a permission on a separate role, - the returned dict will denote that the user has both read and write - access. - ''' - - qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self.resource) - - qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) - qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) - qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) - qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) - qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) - qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) - qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) - qs = qs.annotate(max_use = Max('roles__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 - + return self.resource.get_permissions(user) def accessible_by(self, user, permissions): ''' diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index de95f0e0af..6f59a82618 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -6,6 +6,8 @@ import logging # Django from django.db import models +from django.db.models.aggregates import Max +from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey @@ -116,6 +118,41 @@ class Resource(CommonModelNameNotUnique): object_id = models.PositiveIntegerField(null=True, default=None) content_object = GenericForeignKey('content_type', 'object_id') + def get_permissions(self, user): + ''' + Returns a dict (or None) of the permissions a user has for a given + resource. + + Note: Each field in the dict is the `or` of all respective permissions + that have been granted to the roles that are applicable for the given + user. + + In example, if a user has been granted read access through a permission + on one role and write access through a permission on a separate role, + the returned dict will denote that the user has both read and write + access. + ''' + + qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self) + + qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) + qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) + qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) + qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) + qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) + qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) + qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) + qs = qs.annotate(max_use = Max('roles__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): ''' From dce474ec5e42fd9ba79c97c513e7470a4014327e Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 14:55:32 -0500 Subject: [PATCH 083/297] get_absolute_url implemenation for Role --- awx/main/models/rbac.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 6f59a82618..d1aca6e325 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -20,7 +20,7 @@ __all__ = ['Role', 'RolePermission', 'Resource', 'ROLE_SINGLETON_SYSTEM_ADMINIST logger = logging.getLogger('awx.main.models.rbac') ROLE_SINGLETON_SYSTEM_ADMINISTRATOR='System Administrator' -ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' +ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' class Role(CommonModelNameNotUnique): @@ -45,6 +45,9 @@ class Role(CommonModelNameNotUnique): super(Role, self).save(*args, **kwargs) self.rebuild_role_ancestor_list() + def get_absolute_url(self): + return reverse('api:role_detail', args=(self.pk,)) + def rebuild_role_ancestor_list(self): ''' Updates our `ancestors` map to accurately reflect all of the ancestors for a role From b08809f7cc4504b2c1f46be72522edeb662783e2 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 16:21:56 -0500 Subject: [PATCH 084/297] Initial RBAC API implementation --- awx/api/serializers.py | 139 +++++--- awx/api/urls.py | 25 +- awx/api/views.py | 194 +++++++++-- awx/main/access.py | 60 ++++ awx/main/models/__init__.py | 1 + awx/main/models/rbac.py | 29 ++ awx/main/signals.py | 1 - awx/main/tests/functional/conftest.py | 23 +- awx/main/tests/functional/test_rbac_api.py | 365 +++++++++++++++++++++ 9 files changed, 756 insertions(+), 81 deletions(-) create mode 100644 awx/main/tests/functional/test_rbac_api.py 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 From 26dc430c59b9177608ffa2925ba922306c9bc232 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 16:25:09 -0500 Subject: [PATCH 085/297] Look for and report on transaction errors within our implicit RBAC fields When a transaction is in a failed state these fields will not be able to create new role/resource entries. This check just makes it easier to see what's going on and aids in debugging. --- awx/main/fields.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/awx/main/fields.py b/awx/main/fields.py index e5f6e5d0f8..15224d43fd 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Django +from django.db import connection from django.db.models.signals import post_save from django.db.models.signals import m2m_changed from django.db import models @@ -14,6 +15,7 @@ from django.db.models.fields.related import ( ) from django.core.exceptions import FieldError +from django.db.transaction import TransactionManagementError # AWX @@ -63,6 +65,8 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) if resource: return resource + if connection.needs_rollback: + raise TransactionManagementError('Current transaction has failed, cannot create implicit resource') resource = Resource.objects.create(content_object=instance) setattr(instance, self.field.name, resource) instance.save(update_fields=[self.field.name,]) @@ -107,6 +111,9 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if not self.role_name: raise FieldError('Implicit role missing `role_name`') + if connection.needs_rollback: + raise TransactionManagementError('Current transaction has failed, cannot create implicit role') + role = Role.objects.create(name=self.role_name, content_object=instance) if self.parent_role: def resolve_field(obj, field): From 73b2105a301a01d57ead9b7fbeff01cb08ae525d Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 16:27:11 -0500 Subject: [PATCH 086/297] Switch to using const's for system admin / system auditor singleton names --- awx/main/models/organization.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index b08f068060..0cd50d9dcc 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -18,6 +18,7 @@ from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.models.base import * # noqa +from awx.main.models.rbac import ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR from awx.main.models.mixins import ResourceMixin from awx.main.conf import tower_settings @@ -50,12 +51,12 @@ class Organization(CommonModel, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Organization Administrator', - parent_role='singleton:System Administrator', + parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Organization Auditor', - parent_role='singleton:System Auditor', + parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, permissions = {'read': True} ) member_role = ImplicitRoleField( From 9be9cf9b72754835004d3e33f338b35ade8f5904 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 22 Feb 2016 16:50:13 -0500 Subject: [PATCH 087/297] Fixed RBAC migration tests considering new signal handlers that are a bit too helpful during testing We have some signal handlers now that perform work that do work automatically that we want to explicitly test in our migration path, so we have to undo some things in order to test the migration code. --- awx/main/tests/functional/test_rbac_organization.py | 2 +- awx/main/tests/functional/test_rbac_project.py | 4 +++- awx/main/tests/functional/test_rbac_team.py | 3 +++ awx/main/tests/functional/test_rbac_user.py | 5 +++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 39e21f36e3..6f4a2e0623 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -11,7 +11,7 @@ from django.apps import apps @pytest.mark.django_db def test_organization_migration_admin(organization, permissions, user): - u = user('admin', True) + u = user('admin', False) organization.admins.add(u) assert not organization.accessible_by(u, permissions['admin']) diff --git a/awx/main/tests/functional/test_rbac_project.py b/awx/main/tests/functional/test_rbac_project.py index f7625aaa31..c9b7ffd807 100644 --- a/awx/main/tests/functional/test_rbac_project.py +++ b/awx/main/tests/functional/test_rbac_project.py @@ -1,7 +1,7 @@ import pytest from awx.main.migrations import _rbac as rbac -from awx.main.models import Permission +from awx.main.models import Permission, Role from django.apps import apps from awx.main.migrations import _old_access as old_access @@ -24,6 +24,8 @@ def test_project_user_project(user_project, project, user): @pytest.mark.django_db def test_project_accessible_by_sa(user, project): u = user('systemadmin', is_superuser=True) + # This gets setup by a signal, but we want to test the migration which will set this up too, so remove it + Role.singleton('System Administrator').members.remove(u) assert project.accessible_by(u, {'read': True}) is False rbac.migrate_organization(apps, None) diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 2d0e709632..ad10351fa9 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -10,6 +10,9 @@ def test_team_migration_user(team, user, permissions): team.users.add(u) team.save() + # This gets setup by a signal handler, but we want to test the migration, so remove the user + team.member_role.members.remove(u) + assert not team.accessible_by(u, permissions['auditor']) migrated = rbac.migrate_team(apps, None) diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index f670b26220..c2a41769f5 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -10,11 +10,16 @@ def test_user_admin(user_project, project, user): admin = user('admin', is_superuser = True) sa = Role.singleton('System Administrator') + # this should happen automatically with our signal + assert sa.members.filter(id=admin.id).exists() is True + sa.members.remove(admin) + assert sa.members.filter(id=joe.id).exists() is False assert sa.members.filter(id=admin.id).exists() is False migrations = rbac.migrate_users(apps, None) + # The migration should add the admin back in assert sa.members.filter(id=joe.id).exists() is False assert sa.members.filter(id=admin.id).exists() is True assert len(migrations) == 1 From 606501749c6bcf96a1dc728b7c04d9358b5e0b3f Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 23 Feb 2016 11:49:53 -0500 Subject: [PATCH 088/297] Added several related fields to the RBAC API --- awx/api/serializers.py | 84 ++++++++++++++++++++++++++++++++---------- awx/api/urls.py | 2 +- awx/api/views.py | 11 ++++++ 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 40ab2d3cd2..f258470f48 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -96,6 +96,46 @@ SUMMARIZABLE_FK_FIELDS = { } +def reverseGenericForeignKey(content_object): + ''' + Computes a reverse for a GenericForeignKey field. + + Returns a dictionary of the form + { '': reverse() } + for example + { 'organization': '/api/v1/organizations/1/' } + ''' + + ret = {} + if type(content_object) is Organization: + ret['organization'] = reverse('api:organization_detail', args=(content_object.pk,)) + if type(content_object) is User: + ret['user'] = reverse('api:user_detail', args=(content_object.pk,)) + if type(content_object) is Team: + ret['team'] = reverse('api:team_detail', args=(content_object.pk,)) + if type(content_object) is Project: + ret['project'] = reverse('api:project_detail', args=(content_object.pk,)) + if type(content_object) is Inventory: + ret['inventory'] = reverse('api:inventory_detail', args=(content_object.pk,)) + if type(content_object) is Host: + ret['host'] = reverse('api:host_detail', args=(content_object.pk,)) + if type(content_object) is Group: + ret['group'] = reverse('api:group_detail', args=(content_object.pk,)) + if type(content_object) is InventorySource: + ret['inventory_source'] = reverse('api:inventory_source_detail', args=(content_object.pk,)) + if type(content_object) is Credential: + ret['credential'] = reverse('api:credential_detail', args=(content_object.pk,)) + if type(content_object) is JobTemplate: + ret['job_template'] = reverse('api:job_template_detail', args=(content_object.pk,)) + if type(content_object) is Role: + ret['role'] = reverse('api:role_detail', args=(content_object.pk,)) + if type(content_object) is Job: + ret['job'] = reverse('api:job_detail', args=(content_object.pk,)) + if type(content_object) is JobEvent: + ret['job_event'] = reverse('api:job_event_detail', args=(content_object.pk,)) + return ret + + class BaseSerializerMetaclass(serializers.SerializerMetaclass): ''' Custom metaclass to enable attribute inheritance from Meta objects on @@ -1421,25 +1461,16 @@ class RoleSerializer(BaseSerializer): def get_related(self, obj): 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,)) + ret['users'] = reverse('api:role_users_list', args=(obj.pk,)) + ret['teams'] = reverse('api:role_teams_list', args=(obj.pk,)) + try: + if obj.content_object: + ret.update(reverseGenericForeignKey(obj.content_object)) + except AttributeError: + # AttributeError's happen if our content_object is pointing at + # a model that no longer exists. This is dirty data and ideally + # doesn't exist, but in case it does, let's not puke. + pass return ret @@ -1450,6 +1481,21 @@ class ResourceSerializer(BaseSerializer): model = Resource fields = ('*',) + def get_related(self, obj): + ret = super(ResourceSerializer, self).get_related(obj) + ret['access_list'] = reverse('api:resource_access_list', args=(obj.pk,)) + try: + if obj.content_object: + ret.update(reverseGenericForeignKey(obj.content_object)) + except AttributeError as e: + print(e) + # AttributeError's happen if our content_object is pointing at + # a model that no longer exists. This is dirty data and ideally + # doesn't exist, but in case it does, let's not puke. + pass + + return ret + class ResourceAccessListElementSerializer(UserSerializer): diff --git a/awx/api/urls.py b/awx/api/urls.py index 685c6122e7..a249077c8e 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -151,7 +151,7 @@ role_urls = patterns('awx.api.views', ) resource_urls = patterns('awx.api.views', - #url(r'^$', 'resource_list'), + 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'), diff --git a/awx/api/views.py b/awx/api/views.py index e49741f14e..ad2cffeb19 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -135,6 +135,8 @@ class ApiV1RootView(APIView): data['system_job_templates'] = reverse('api:system_job_template_list') data['system_jobs'] = reverse('api:system_job_list') data['schedules'] = reverse('api:schedule_list') + data['roles'] = reverse('api:role_list') + data['resources'] = reverse('api:resource_list') data['unified_job_templates'] = reverse('api:unified_job_template_list') data['unified_jobs'] = reverse('api:unified_job_list') data['activity_stream'] = reverse('api:activity_stream_list') @@ -3160,6 +3162,15 @@ class ResourceDetail(RetrieveAPIView): def get_queryset(self): return Resource.objects +class ResourceList(ListAPIView): + + model = Resource + serializer_class = ResourceSerializer + new_in_300 = True + + def get_queryset(self): + return Resource.objects.filter(permissions__role__ancestors__members=self.request.user) + class ResourceAccessList(ListAPIView): model = User From 384b8b95428817ab8ba1f1d9371f4ffb3cb5ce4a Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 23 Feb 2016 16:11:34 -0500 Subject: [PATCH 089/297] Added 'resource_access_list' related field to resources --- awx/api/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f258470f48..f68d41563f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -299,6 +299,7 @@ class BaseSerializer(serializers.ModelSerializer): 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,)) + res['resource_access_list'] = reverse('api:resource_access_list', args=(obj.resource_id,)) return res def _get_summary_fields(self, obj): From 180911dfa8ea26d6495a745339ae296bb12b79b9 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 24 Feb 2016 14:03:05 -0500 Subject: [PATCH 090/297] Added UserResource --- awx/main/migrations/0003_rbac_changes.py | 28 +++++++++++++++++ awx/main/migrations/_rbac.py | 5 +++ awx/main/models/__init__.py | 1 + awx/main/models/user.py | 30 ++++++++++++++++++ awx/main/signals.py | 31 ++++++++++++++++++- .../functional/test_rbac_userresource.py | 15 +++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 awx/main/models/user.py create mode 100644 awx/main/tests/functional/test_rbac_userresource.py diff --git a/awx/main/migrations/0003_rbac_changes.py b/awx/main/migrations/0003_rbac_changes.py index 23aee5f92d..59468e2325 100644 --- a/awx/main/migrations/0003_rbac_changes.py +++ b/awx/main/migrations/0003_rbac_changes.py @@ -249,4 +249,32 @@ class Migration(migrations.Migration): name='resource', field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), ), + + migrations.CreateModel( + name='UserResource', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('active', models.BooleanField(default=True, editable=False)), + ('name', models.CharField(max_length=512)), + ('admin_role', awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True')), + ('created_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('resource', awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True')), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ('user', awx.main.fields.AutoOneToOneField(related_name='resource', editable=False, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'main_rbac_user_resource', + 'verbose_name': 'user_resource', + 'verbose_name_plural': 'user_resources', + }, + ), + migrations.AlterUniqueTogether( + name='userresource', + unique_together=set([('user', 'admin_role')]), + ), + ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 6b96bb943b..414a5009de 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -5,7 +5,12 @@ def migrate_users(apps, schema_editor): migrations = list() User = apps.get_model('auth', "User") Role = apps.get_model('main', "Role") + UserResource = apps.get_model('main', "UserResource") + for user in User.objects.all(): + ur = UserResource.objects.create(user=user) + ur.admin_role.members.add(user) + if user.is_superuser: Role.singleton('System Administrator').members.add(user) migrations.append(user) diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index fe505ff308..85476a19e7 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.user import * # noqa from awx.main.models.mixins import * # noqa # Monkeypatch Django serializer to ignore django-taggit fields (which break diff --git a/awx/main/models/user.py b/awx/main/models/user.py new file mode 100644 index 0000000000..c30696bdb1 --- /dev/null +++ b/awx/main/models/user.py @@ -0,0 +1,30 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from awx.main.models.base import CommonModelNameNotUnique +from awx.main.models.mixins import ResourceMixin +from awx.main.fields import AutoOneToOneField, ImplicitRoleField + + +class UserResource(CommonModelNameNotUnique, ResourceMixin): + class Meta: + app_label = 'main' + verbose_name = _('user_resource') + verbose_name_plural = _('user_resources') + unique_together = [('user', 'admin_role'),] + db_table = 'main_rbac_user_resource' + + user = AutoOneToOneField( + 'auth.User', + on_delete=models.CASCADE, + related_name='resource', + editable=False, + ) + + admin_role = ImplicitRoleField( + role_name='User Administrator', + permissions = {'all': True}, + ) diff --git a/awx/main/signals.py b/awx/main/signals.py index 15821e3e32..8de53ee4b6 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -147,6 +147,34 @@ def sync_user_to_team_members_role(sender, reverse, model, instance, pk_set, act instance.member_role.members.remove(user) +def sync_user_to_org_members_role(sender, reverse, model, instance, pk_set, action, **kwargs): + 'When a user is added or removed from Organization.users, ensure that is reflected in Organization.member_role' + if action == 'post_add' or action == 'pre_remove': + if reverse: + for org in Organization.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + org.member_role.members.add(instance) + org.admin_role.children.add(instance.resource.admin_role) + if action == 'pre_remove': + org.member_role.members.remove(instance) + org.admin_role.children.remove(instance.resource.admin_role) + else: + for user in User.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + instance.member_role.members.add(user) + instance.admin_role.children.add(user.resource.admin_role) + if action == 'pre_remove': + instance.member_role.members.remove(user) + instance.admin_role.children.remove(user.resource.admin_role) + +def create_user_resource(sender, **kwargs): + instance = kwargs['instance'] + try: + UserResource.objects.get(user=instance) + except UserResource.DoesNotExist: + ur = UserResource.objects.create(user=instance) + ur.admin_role.members.add(instance) + pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -168,7 +196,8 @@ post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) m2m_changed.connect(sync_user_to_team_members_role, Team.users.through) -#m2m_changed.connect(rebuild_group_parent_roles, Group.parents.through) +post_save.connect(create_user_resource, sender=User) +m2m_changed.connect(sync_user_to_org_members_role, Organization.users.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. diff --git a/awx/main/tests/functional/test_rbac_userresource.py b/awx/main/tests/functional/test_rbac_userresource.py new file mode 100644 index 0000000000..f666fb9eb0 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_userresource.py @@ -0,0 +1,15 @@ +import pytest + +@pytest.mark.django_db +def test_user_resource_org_admin(user, organization): + admin = user('orgadmin') + member = user('orgmember') + + member.organizations.add(organization) + assert not member.resource.accessible_by(admin, {'write':True}) + + organization.admin_role.members.add(admin) + assert member.resource.accessible_by(admin, {'write':True}) + + organization.admin_role.members.remove(admin) + assert not member.resource.accessible_by(admin, {'write':True}) From 5eee8e3a84f2df42dc0864576e9a5823e0aafc1d Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 24 Feb 2016 14:58:13 -0500 Subject: [PATCH 091/297] Added RBAC sync for Organization.admins --- awx/main/signals.py | 16 ++++++++++ .../functional/test_rbac_userresource.py | 29 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/awx/main/signals.py b/awx/main/signals.py index 8de53ee4b6..6451da0fe6 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -146,6 +146,21 @@ def sync_user_to_team_members_role(sender, reverse, model, instance, pk_set, act if action == 'pre_remove': instance.member_role.members.remove(user) +def sync_admin_to_org_admin_role(sender, reverse, model, instance, pk_set, action, **kwargs): + 'When a user is added or removed from Organization.admins, ensure that is reflected in Organization.admin_role' + if action == 'post_add' or action == 'pre_remove': + if reverse: + for org in Organization.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + org.admin_role.members.add(instance) + if action == 'pre_remove': + org.admin_role.members.remove(instance) + else: + for user in User.objects.filter(id__in=pk_set).all(): + if action == 'post_add': + instance.admin_role.members.add(user) + if action == 'pre_remove': + instance.admin_role.members.remove(user) def sync_user_to_org_members_role(sender, reverse, model, instance, pk_set, action, **kwargs): 'When a user is added or removed from Organization.users, ensure that is reflected in Organization.member_role' @@ -198,6 +213,7 @@ post_save.connect(sync_superuser_status_to_rbac, sender=User) m2m_changed.connect(sync_user_to_team_members_role, Team.users.through) post_save.connect(create_user_resource, sender=User) m2m_changed.connect(sync_user_to_org_members_role, Organization.users.through) +m2m_changed.connect(sync_admin_to_org_admin_role, Organization.admins.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. diff --git a/awx/main/tests/functional/test_rbac_userresource.py b/awx/main/tests/functional/test_rbac_userresource.py index f666fb9eb0..f5e83438df 100644 --- a/awx/main/tests/functional/test_rbac_userresource.py +++ b/awx/main/tests/functional/test_rbac_userresource.py @@ -1,7 +1,7 @@ import pytest @pytest.mark.django_db -def test_user_resource_org_admin(user, organization): +def test_user_org_admin(user, organization): admin = user('orgadmin') member = user('orgmember') @@ -13,3 +13,30 @@ def test_user_resource_org_admin(user, organization): organization.admin_role.members.remove(admin) assert not member.resource.accessible_by(admin, {'write':True}) + +@pytest.mark.django_db +def test_org_user_admin(user, organization): + admin = user('orgadmin') + member = user('orgmember') + + organization.users.add(member) + assert not member.resource.accessible_by(admin, {'write':True}) + + organization.admins.add(admin) + assert member.resource.accessible_by(admin, {'write':True}) + + organization.admins.remove(admin) + assert not member.resource.accessible_by(admin, {'write':True}) + +@pytest.mark.django_db +def test_org_user_removed(user, organization): + admin = user('orgadmin') + member = user('orgmember') + + organization.admins.add(admin) + organization.users.add(member) + + assert member.resource.accessible_by(admin, {'write':True}) + + organization.users.remove(member) + assert not member.resource.accessible_by(admin, {'write':True}) From 5bb241bfd4275c9df1f857a352da9e6d482712d1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 26 Feb 2016 10:42:07 -0500 Subject: [PATCH 092/297] Added resource name and related field to the roles listed in an access_list --- awx/api/serializers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index cd5e80dac6..bbd2e1fa68 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1532,7 +1532,14 @@ class ResourceAccessListElementSerializer(UserSerializer): 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)} + role_dict = { 'id': role.id, 'name': role.name} + try: + role_dict['resource_name'] = role.content_object.name + role_dict['related'] = reverseGenericForeignKey(role.content_object) + except: + pass + + return { 'role': role_dict, '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() From 9c1694f187f4b06d2a1a2e38304d2756ac309a71 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Fri, 26 Feb 2016 10:46:01 -0500 Subject: [PATCH 093/297] Added resource type to our roles in our access list --- awx/api/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index bbd2e1fa68..25e438858c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1535,6 +1535,7 @@ class ResourceAccessListElementSerializer(UserSerializer): role_dict = { 'id': role.id, 'name': role.name} try: role_dict['resource_name'] = role.content_object.name + role_dict['resource_type'] = role.content_type.name role_dict['related'] = reverseGenericForeignKey(role.content_object) except: pass From e94d441fb0f7beb497981414f1949ed46d35ea07 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Mon, 29 Feb 2016 16:59:20 -0500 Subject: [PATCH 094/297] Add support for following parental changes on save and delete in the RBAC system --- awx/main/fields.py | 162 ++++++++++++++++---- awx/main/models/rbac.py | 51 +++++- awx/main/tests/functional/test_rbac_core.py | 41 ++++- 3 files changed, 215 insertions(+), 39 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 15224d43fd..1db59e296f 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -3,7 +3,11 @@ # Django from django.db import connection -from django.db.models.signals import post_save +from django.db.models.signals import ( + post_init, + post_save, + post_delete, +) from django.db.models.signals import m2m_changed from django.db import models from django.db.models.fields.related import ( @@ -69,7 +73,8 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): raise TransactionManagementError('Current transaction has failed, cannot create implicit resource') resource = Resource.objects.create(content_object=instance) setattr(instance, self.field.name, resource) - instance.save(update_fields=[self.field.name,]) + if instance.pk: + instance.save(update_fields=[self.field.name,]) return resource @@ -85,12 +90,45 @@ class ImplicitResourceField(models.ForeignKey): def contribute_to_class(self, cls, name): super(ImplicitResourceField, self).contribute_to_class(cls, name) setattr(cls, self.name, ResourceFieldDescriptor(self)) - post_save.connect(self._save, cls, True) + post_save.connect(self._post_save, cls, True) + post_delete.connect(self._post_delete, cls, True) + + def _post_save(self, instance, *args, **kwargs): + # Ensures our resource object exists and that it's content_object + # points back to our hosting instance. + this_resource = getattr(instance, self.name) + if not this_resource.object_id: + this_resource.content_object = instance + this_resource.save() + + def _post_delete(self, instance, *args, **kwargs): + getattr(instance, self.name).delete() + + + + +def resolve_role_field(obj, field): + ret = [] + + field_components = field.split('.', 1) + if hasattr(obj, field_components[0]): + obj = getattr(obj, field_components[0]) + else: + return [] + + if len(field_components) == 1: + if type(obj) is not ImplicitRoleDescriptor and type(obj) is not Role: + raise Exception('%s refers to a %s, not an ImplicitRoleField or Role' % (field, str(type(obj)))) + ret.append(obj) + else: + if type(obj) is ManyRelatedObjectsDescriptor: + for o in obj.all(): + ret += resolve_role_field(o, field_components[1]) + else: + ret += resolve_role_field(obj, field_components[1]) + + return ret - def _save(self, instance, *args, **kwargs): - # Ensure that our field gets initialized after our first save - if not hasattr(instance, self.name): - getattr(instance, self.name) class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): @@ -116,27 +154,6 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): role = Role.objects.create(name=self.role_name, content_object=instance) if self.parent_role: - def resolve_field(obj, field): - ret = [] - - field_components = field.split('.', 1) - if hasattr(obj, field_components[0]): - obj = getattr(obj, field_components[0]) - else: - return [] - - if len(field_components) == 1: - if type(obj) is not ImplicitRoleDescriptor and type(obj) is not Role: - raise Exception('%s refers to a %s, not an ImplicitRoleField or Role' % (field, str(type(obj)))) - ret.append(obj) - else: - if type(obj) is ManyRelatedObjectsDescriptor: - for o in obj.all(): - ret += resolve_field(o, field_components[1]) - else: - ret += resolve_field(obj, field_components[1]) - - return ret # Add all non-null parent roles as parents paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] @@ -144,11 +161,12 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if path.startswith("singleton:"): parents = [Role.singleton(path[10:])] else: - parents = resolve_field(instance, path) + parents = resolve_role_field(instance, path) for parent in parents: role.parents.add(parent) setattr(instance, self.field.name, role) - instance.save(update_fields=[self.field.name,]) + if instance.pk: + instance.save(update_fields=[self.field.name,]) if self.permissions is not None: permissions = RolePermission( @@ -198,7 +216,9 @@ class ImplicitRoleField(models.ForeignKey): self ) ) - post_save.connect(self._save, cls, True) + post_init.connect(self._post_init, cls, True) + post_save.connect(self._post_save, cls, True) + post_delete.connect(self._post_delete, cls, True) add_lazy_relation(cls, self, "self", self.bind_m2m_changed) def bind_m2m_changed(self, _self, _role_class, cls): @@ -263,7 +283,81 @@ class ImplicitRoleField(models.ForeignKey): getattr(instance, self.name).parents.remove(getattr(obj, self.m2m_field_attr)) - def _save(self, instance, *args, **kwargs): + def _post_init(self, instance, *args, **kwargs): + if not self.parent_role: + return + #if not hasattr(instance, self.name): + # getattr(instance, self.name) + + if not hasattr(self, '__original_parent_roles'): + paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] + all_parents = set() + for path in paths: + if path.startswith("singleton:"): + parents = [Role.singleton(path[10:])] + else: + parents = resolve_role_field(instance, path) + for parent in parents: + all_parents.add(parent) + #role.parents.add(parent) + self.__original_parent_roles = all_parents + + ''' + field_names = self.parent_role + if type(field_names) is not list: + field_names = [field_names] + self.__original_values = {} + for field_name in field_names: + if field_name.startswith('singleton:'): + continue + first_field_name = field_name.split('.')[0] + self.__original_values[first_field_name] = getattr(instance, first_field_name) + ''' + else: + print('WE DO NEED THIS') + pass + + def _post_save(self, instance, *args, **kwargs): # Ensure that our field gets initialized after our first save - if not hasattr(instance, self.name): - getattr(instance, self.name) + this_role = getattr(instance, self.name) + if not this_role.object_id: + # Ensure our ref back to our instance is set. This will not be set the + # first time the object is saved because we create the role in our _post_init + # but that happens before an id for the instance has been set (because it + # hasn't been saved yet!). Now that everything has an id, we patch things + # so the role references the instance. + this_role.content_object = instance + this_role.save() + + # As object relations change, the role hierarchy might also change if the relations + # that changed were referenced in our magic parent_role field. This code synchronizes + # these changes. + if not self.parent_role: + return + + paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] + original_parents = self.__original_parent_roles + new_parents = set() + for path in paths: + if path.startswith("singleton:"): + parents = [Role.singleton(path[10:])] + else: + parents = resolve_role_field(instance, path) + for parent in parents: + new_parents.add(parent) + + Role.pause_role_ancestor_rebuilding() + for role in original_parents - new_parents: + this_role.parents.remove(role) + for role in new_parents - original_parents: + this_role.parents.add(role) + Role.unpause_role_ancestor_rebuilding() + + self.__original_parent_roles = new_parents + + def _post_delete(self, instance, *args, **kwargs): + this_role = getattr(instance, self.name) + children = [c for c in this_role.children.all()] + this_role.delete() + for child in children: + children.rebuild_role_ancestor_list() diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index bdf33e0a84..396dcd71c3 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -15,13 +15,23 @@ from django.contrib.contenttypes.fields import GenericForeignKey # AWX from awx.main.models.base import * # noqa -__all__ = ['Role', 'RolePermission', 'Resource', 'ROLE_SINGLETON_SYSTEM_ADMINISTRATOR', 'ROLE_SINGLETON_SYSTEM_AUDITOR'] +__all__ = [ + 'Role', + 'RolePermission', + 'Resource', + 'ROLE_SINGLETON_SYSTEM_ADMINISTRATOR', + 'ROLE_SINGLETON_SYSTEM_AUDITOR', +] logger = logging.getLogger('awx.main.models.rbac') ROLE_SINGLETON_SYSTEM_ADMINISTRATOR='System Administrator' ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' +role_rebuilding_paused = False +roles_needing_rebuilding = set() + + class Role(CommonModelNameNotUnique): ''' @@ -48,6 +58,36 @@ class Role(CommonModelNameNotUnique): def get_absolute_url(self): return reverse('api:role_detail', args=(self.pk,)) + @staticmethod + def pause_role_ancestor_rebuilding(): + ''' + Pauses role ancestor list updating. This is useful when you're making + many changes to the same roles, for example doing bulk inserts or + making many changes to the same object in succession. + + Note that the unpause_role_ancestor_rebuilding MUST be called within + the same execution context (preferably within the same transaction), + otherwise the RBAC role ancestor hierarchy will not be properly + updated. + ''' + + global role_rebuilding_paused + role_rebuilding_paused = True + + @staticmethod + def unpause_role_ancestor_rebuilding(): + ''' + Unpauses the role ancestor list updating. This will will rebuild all + roles that need updating since the last call to + pause_role_ancestor_rebuilding and bring everything back into sync. + ''' + global role_rebuilding_paused + global roles_needing_rebuilding + role_rebuilding_paused = False + for role in Role.objects.filter(id__in=list(roles_needing_rebuilding)).all(): + role.rebuild_role_ancestor_list() + roles_needing_rebuilding = set() + def rebuild_role_ancestor_list(self): ''' Updates our `ancestors` map to accurately reflect all of the ancestors for a role @@ -57,6 +97,11 @@ class Role(CommonModelNameNotUnique): Note that this method relies on any parents' ancestor list being correct. ''' + global role_rebuilding_paused, roles_needing_rebuilding + + if role_rebuilding_paused: + roles_needing_rebuilding.add(self.id) + return actual_ancestors = set(Role.objects.filter(id=self.id).values_list('parents__ancestors__id', flat=True)) actual_ancestors.add(self.id) @@ -67,9 +112,9 @@ class Role(CommonModelNameNotUnique): # If it differs, update, and then update all of our children if actual_ancestors != stored_ancestors: for id in actual_ancestors - stored_ancestors: - self.ancestors.add(Role.objects.get(id=id)) + self.ancestors.add(id) for id in stored_ancestors - actual_ancestors: - self.ancestors.remove(Role.objects.get(id=id)) + self.ancestors.remove(id) for child in self.children.all(): child.rebuild_role_ancestor_list() diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py index b31ef310b0..020023f9bd 100644 --- a/awx/main/tests/functional/test_rbac_core.py +++ b/awx/main/tests/functional/test_rbac_core.py @@ -2,6 +2,7 @@ import pytest from awx.main.models import ( Role, + Resource, Organization, ) @@ -90,14 +91,50 @@ def test_auto_m2m_adjuments(organization, project, alice): assert project.accessible_by(alice, {'read': True}) is True @pytest.mark.django_db -@pytest.mark.skipif(True, reason='Unimplemented') def test_auto_field_adjuments(organization, inventory, team, alice): - 'Ensures the auto role reparenting is working correctly through m2m maps' + 'Ensures the auto role reparenting is working correctly through non m2m fields' org2 = Organization.objects.create(name='Org 2', description='org 2') org2.admin_role.members.add(alice) assert inventory.accessible_by(alice, {'read': True}) is False inventory.organization = org2 + inventory.save() assert inventory.accessible_by(alice, {'read': True}) is True inventory.organization = organization + inventory.save() assert inventory.accessible_by(alice, {'read': True}) is False + #assert False + +@pytest.mark.django_db +def test_implicit_deletes(alice): + 'Ensures implicit resources and roles delete themselves' + delorg = Organization.objects.create(name='test-org') + delorg.admin_role.members.add(alice) + + resource_id = delorg.resource.id + admin_role_id = delorg.admin_role.id + auditor_role_id = delorg.auditor_role.id + + assert Role.objects.filter(id=admin_role_id).count() == 1 + assert Role.objects.filter(id=auditor_role_id).count() == 1 + assert Resource.objects.filter(id=resource_id).count() == 1 + n_alice_roles = alice.roles.count() + n_system_admin_children = Role.singleton('System Administrator').children.count() + + delorg.delete() + + assert Role.objects.filter(id=admin_role_id).count() == 0 + assert Role.objects.filter(id=auditor_role_id).count() == 0 + assert Resource.objects.filter(id=resource_id).count() == 0 + assert alice.roles.count() == (n_alice_roles - 1) + assert Role.singleton('System Administrator').children.count() == (n_system_admin_children - 1) + +@pytest.mark.django_db +def test_content_object(user): + 'Ensure our conent_object stuf seems to be working' + + print('Creating organization') + org = Organization.objects.create(name='test-org') + print('Organizaiton id: %d resource: %d admin_role: %d' % (org.id, org.resource.id, org.admin_role.id)) + assert org.resource.content_object.id == org.id + assert org.admin_role.content_object.id == org.id From 73dc0617166fc3e98f382decda04e4c03998cf11 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 09:47:26 -0500 Subject: [PATCH 095/297] Patch up our credential migration tests to undo some automatic work that needs to be done in the migration --- awx/main/tests/functional/test_rbac_credential.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 2e44442528..e2febb456d 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -30,7 +30,10 @@ def test_credential_migration_team_member(credential, team, user, permissions): credential.team = team credential.save() - # No permissions pre-migration + + # No permissions pre-migration (this happens automatically so we patch this) + team.admin_role.children.remove(credential.owner_role) + team.member_role.children.remove(credential.usage_role) assert not credential.accessible_by(u, permissions['admin']) migrated = rbac.migrate_credential(apps, None) @@ -47,6 +50,8 @@ def test_credential_migration_team_admin(credential, team, user, permissions): credential.save() # No permissions pre-migration + team.admin_role.children.remove(credential.owner_role) + team.member_role.children.remove(credential.usage_role) assert not credential.accessible_by(u, permissions['usage']) # Usage permissions post migration From 41c06dc2d0542da49f0ad6667d9740f676d70cc9 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 09:54:35 -0500 Subject: [PATCH 096/297] Update user migration to not bomb out when a UserResource already exists for a user --- awx/main/migrations/_rbac.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 414a5009de..546721c4f6 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -5,10 +5,9 @@ def migrate_users(apps, schema_editor): migrations = list() User = apps.get_model('auth', "User") Role = apps.get_model('main', "Role") - UserResource = apps.get_model('main', "UserResource") for user in User.objects.all(): - ur = UserResource.objects.create(user=user) + ur = user.resource # implicitly creates the UserResource field if it didn't already exist ur.admin_role.members.add(user) if user.is_superuser: From f5e311f5ac3f7829134f774760fcded5f01f79e7 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 09:56:43 -0500 Subject: [PATCH 097/297] Undo some more automatic work that we're suppsoed to test with our migrations --- awx/main/tests/functional/test_rbac_organization.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index 6f4a2e0623..c8f1d709a2 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -14,6 +14,8 @@ def test_organization_migration_admin(organization, permissions, user): u = user('admin', False) organization.admins.add(u) + # Undo some automatic work that we're supposed to be testing with our migration + organization.admin_role.members.remove(u) assert not organization.accessible_by(u, permissions['admin']) migrations = rbac.migrate_organization(apps, None) @@ -26,6 +28,8 @@ def test_organization_migration_user(organization, permissions, user): u = user('user', False) organization.users.add(u) + # Undo some automatic work that we're supposed to be testing with our migration + organization.member_role.members.remove(u) assert not organization.accessible_by(u, permissions['auditor']) migrations = rbac.migrate_organization(apps, None) From a4b35676192d8d236b372d1ad1349d17a2dcc5fe Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 29 Feb 2016 15:34:57 -0500 Subject: [PATCH 098/297] Added migration to remove users/admins FK from Org/Teams --- awx/main/migrations/0005_rbac_remove_users.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 awx/main/migrations/0005_rbac_remove_users.py diff --git a/awx/main/migrations/0005_rbac_remove_users.py b/awx/main/migrations/0005_rbac_remove_users.py new file mode 100644 index 0000000000..535e78a73a --- /dev/null +++ b/awx/main/migrations/0005_rbac_remove_users.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0004_rbac_migrations'), + ] + + operations = [ + migrations.RemoveField( + model_name='organization', + name='admins', + ), + migrations.RemoveField( + model_name='organization', + name='users', + ), + migrations.RemoveField( + model_name='team', + name='users', + ), + ] From 380ccec687dc739d460d215d3f40279176e31c4d Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 29 Feb 2016 15:36:51 -0500 Subject: [PATCH 099/297] started access refactoring, added UserAccess and updated how ALL permissions is checked --- awx/main/access.py | 41 ++++++++++++++---------- awx/main/models/mixins.py | 2 +- awx/main/models/organization.py | 10 ++++-- awx/main/models/rbac.py | 17 ++-------- awx/main/signals.py | 55 --------------------------------- 5 files changed, 35 insertions(+), 90 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 5a7ec03263..cf41ac08af 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -16,9 +16,9 @@ from rest_framework.exceptions import ParseError, PermissionDenied # AWX from awx.main.utils import * # noqa from awx.main.models import * # noqa +from awx.main.models.rbac import ALL_PERMISSIONS 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'] @@ -52,6 +52,21 @@ access_registry = { # ... } + +def user_or_team(data): + try: + if 'user' in data: + pk = get_pk_from_dict(data, 'user') + return get_object_or_400(User, pk=pk), None + elif 'team' in data: + pk = get_pk_from_dict(data, 'team') + return None, get_object_or_400(Team, pk=pk) + else: + return None, None + except ParseError: + return None, None + + def register_access(model_class, access_class): access_classes = access_registry.setdefault(model_class, []) access_classes.append(access_class) @@ -193,24 +208,16 @@ class UserAccess(BaseAccess): 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() + qs = self.model.accessible_objects(self.user, {'read':True}) + return qs 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()) + if self.user.is_superuser: + return True + return Organization.accessible_objects(self.user, ALL_PERMISSIONS).filter(active=True).exists() def can_change(self, obj, data): if data is not None and 'is_superuser' in data: @@ -225,7 +232,7 @@ class UserAccess(BaseAccess): # 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()) + return obj.accessible_by(self.user, {'create': True, 'write':True, 'update':True, 'read':True}) def can_delete(self, obj): if obj == self.user: @@ -235,8 +242,8 @@ class UserAccess(BaseAccess): 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()) + return obj.accessible_by(self.user, {'delete': True}) + class OrganizationAccess(BaseAccess): ''' diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 6d069ed3d4..d6bb10754d 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -51,7 +51,7 @@ class ResourceMixin(models.Model): ''' perms = self.get_permissions(user) - if not perms: + if perms is None: return False for k in permissions: if k not in perms or perms[k] < permissions[k]: diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 0cd50d9dcc..9709c2d3d0 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -18,7 +18,11 @@ from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.models.base import * # noqa -from awx.main.models.rbac import ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR +from awx.main.models.rbac import ( + ALL_PERMISSIONS, + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, + ROLE_SINGLETON_SYSTEM_AUDITOR, +) from awx.main.models.mixins import ResourceMixin from awx.main.conf import tower_settings @@ -52,7 +56,7 @@ class Organization(CommonModel, ResourceMixin): admin_role = ImplicitRoleField( role_name='Organization Administrator', parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, - permissions = {'all': True} + permissions = ALL_PERMISSIONS, ) auditor_role = ImplicitRoleField( role_name='Organization Auditor', @@ -108,7 +112,7 @@ class Team(CommonModelNameNotUnique, ResourceMixin): admin_role = ImplicitRoleField( role_name='Team Administrator', parent_role='organization.admin_role', - permissions = {'all': True} + permissions = ALL_PERMISSIONS, ) auditor_role = ImplicitRoleField( role_name='Team Auditor', diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 396dcd71c3..b7b6d50f3e 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -31,7 +31,8 @@ ROLE_SINGLETON_SYSTEM_AUDITOR='System Auditor' role_rebuilding_paused = False roles_needing_rebuilding = set() - +ALL_PERMISSIONS = {'create': True, 'read': True, 'update': True, 'delete': True, + 'write': True, 'scm_update': True, 'use': True, 'execute': True} class Role(CommonModelNameNotUnique): ''' @@ -122,18 +123,6 @@ class Role(CommonModelNameNotUnique): def grant(self, resource, permissions): # take either the raw Resource or something that includes the ResourceMixin resource = resource if type(resource) is Resource else resource.resource - - if 'all' in permissions and permissions['all']: - del permissions['all'] - permissions['create'] = True - permissions['read'] = True - permissions['write'] = True - permissions['update'] = True - permissions['delete'] = True - permissions['scm_update'] = True - permissions['use'] = True - permissions['execute'] = True - permission = RolePermission(role=self, resource=resource) for k in permissions: setattr(permission, k, int(permissions[k])) @@ -256,8 +245,8 @@ class RolePermission(CreatedModifiedModel): create = models.IntegerField(default = 0) read = models.IntegerField(default = 0) write = models.IntegerField(default = 0) - update = models.IntegerField(default = 0) delete = models.IntegerField(default = 0) + update = models.IntegerField(default = 0) execute = models.IntegerField(default = 0) scm_update = models.IntegerField(default = 0) use = models.IntegerField(default = 0) diff --git a/awx/main/signals.py b/awx/main/signals.py index 6451da0fe6..01b2bb9d34 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -130,58 +130,6 @@ def sync_superuser_status_to_rbac(sender, instance, **kwargs): else: Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.remove(instance) -def sync_user_to_team_members_role(sender, reverse, model, instance, pk_set, action, **kwargs): - 'When a user is added or removed from Team.users, ensure that is reflected in Team.member_role' - if action == 'post_add' or action == 'pre_remove': - if reverse: - for team in Team.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - team.member_role.members.add(instance) - if action == 'pre_remove': - team.member_role.members.remove(instance) - else: - for user in User.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - instance.member_role.members.add(user) - if action == 'pre_remove': - instance.member_role.members.remove(user) - -def sync_admin_to_org_admin_role(sender, reverse, model, instance, pk_set, action, **kwargs): - 'When a user is added or removed from Organization.admins, ensure that is reflected in Organization.admin_role' - if action == 'post_add' or action == 'pre_remove': - if reverse: - for org in Organization.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - org.admin_role.members.add(instance) - if action == 'pre_remove': - org.admin_role.members.remove(instance) - else: - for user in User.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - instance.admin_role.members.add(user) - if action == 'pre_remove': - instance.admin_role.members.remove(user) - -def sync_user_to_org_members_role(sender, reverse, model, instance, pk_set, action, **kwargs): - 'When a user is added or removed from Organization.users, ensure that is reflected in Organization.member_role' - if action == 'post_add' or action == 'pre_remove': - if reverse: - for org in Organization.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - org.member_role.members.add(instance) - org.admin_role.children.add(instance.resource.admin_role) - if action == 'pre_remove': - org.member_role.members.remove(instance) - org.admin_role.children.remove(instance.resource.admin_role) - else: - for user in User.objects.filter(id__in=pk_set).all(): - if action == 'post_add': - instance.member_role.members.add(user) - instance.admin_role.children.add(user.resource.admin_role) - if action == 'pre_remove': - instance.member_role.members.remove(user) - instance.admin_role.children.remove(user.resource.admin_role) - def create_user_resource(sender, **kwargs): instance = kwargs['instance'] try: @@ -210,10 +158,7 @@ post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) -m2m_changed.connect(sync_user_to_team_members_role, Team.users.through) post_save.connect(create_user_resource, sender=User) -m2m_changed.connect(sync_user_to_org_members_role, Organization.users.through) -m2m_changed.connect(sync_admin_to_org_admin_role, Organization.admins.through) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. From 1d179574af403a063430d0b3528436ea851fe4a8 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 29 Feb 2016 15:37:59 -0500 Subject: [PATCH 100/297] Updated Organization and Credential access --- awx/main/access.py | 61 ++++++------------- .../tests/functional/test_rbac_credential.py | 18 +++--- .../tests/functional/test_rbac_inventory.py | 36 +++++++++++ .../functional/test_rbac_organization.py | 14 ++--- 4 files changed, 71 insertions(+), 58 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index cf41ac08af..3870b1012e 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -258,15 +258,14 @@ class OrganizationAccess(BaseAccess): model = Organization def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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])) + return qs def can_change(self, obj, data): - return bool(self.user.is_superuser or - self.user in obj.admins.all()) + if self.user.is_superuser: + return True + return obj.accessible_by(self.user, ALL_PERMISSIONS) def can_delete(self, obj): self.check_license(feature='multiple_organizations', check_expiration=False) @@ -567,55 +566,29 @@ class CredentialAccess(BaseAccess): """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 = self.model.accessible_objects(self.user, {'read':True}) 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) - ) + return qs 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 self.user.can_access(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 self.user.can_access(Team, 'change', team_obj, None) - return False + + user, team = user_or_team(data) + if user is None and team is None: + return False + + if user is not None: + return user.resource.accessible_by(self.user, {'write': True}) + if team is not None: + return team.accessible_by(self.user, {'write':True}) 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 + return obj.accessible_by(self.user, {'read':True, 'update': True, 'delete':True}) def can_delete(self, obj): # Unassociated credentials may be marked deleted by anyone, though we diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index e2febb456d..ec990472a1 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -69,11 +69,9 @@ def test_credential_access_superuser(): assert access.can_delete(credential) @pytest.mark.django_db -def test_credential_access_admin(user, organization, team, credential): +def test_credential_access_admin(user, team, credential): u = user('org-admin', False) - organization.admins.add(u) - team.organization = organization - team.save() + team.organization.admin_role.members.add(u) access = CredentialAccess(u) @@ -85,10 +83,16 @@ def test_credential_access_admin(user, organization, team, credential): # unowned credential can be deleted assert access.can_delete(credential) - team.users.add(u) - assert not access.can_change(credential, {'user': u.pk}) - + # credential is now part of a team + # that is part of an organization + # that I am an admin for credential.team = team credential.save() + credential.owner_role.rebuild_role_ancestor_list() + cred = Credential.objects.create(kind='aws', name='test-cred') + cred.team = team + cred.save() + + # should have can_change access as org-admin assert access.can_change(credential, {'user': u.pk}) diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 8834545140..d4440f6902 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -195,3 +195,39 @@ def test_group_parent_admin(group, permissions, user): parent2.admin_role.members.add(u) assert childA.accessible_by(u, permissions['admin']) + +@pytest.mark.django_db +def test_access_admin(organization, inventory, user): + a = user('admin', False) + inventory.organization = organization + organization.admin_role.members.add(a) + + access = InventoryAccess(a) + assert access.can_read(inventory) + assert access.can_add(None) + assert access.can_add({'organization': organization.id}) + assert access.can_change(inventory, None) + assert access.can_change(inventory, {'organization': organization.id}) + assert access.can_admin(inventory, None) + assert access.can_admin(inventory, {'organization': organization.id}) + assert access.can_delete(inventory) + assert access.can_run_ad_hoc_commands(inventory) + +@pytest.mark.django_db +def test_access_auditor(organization, inventory, user): + u = user('admin', False) + inventory.organization = organization + organization.auditor_role.members.add(u) + + access = InventoryAccess(u) + assert access.can_read(inventory) + assert not access.can_add(None) + assert not access.can_add({'organization': organization.id}) + assert not access.can_change(inventory, None) + assert not access.can_change(inventory, {'organization': organization.id}) + assert not access.can_admin(inventory, None) + assert not access.can_admin(inventory, {'organization': organization.id}) + assert not access.can_delete(inventory) + assert not access.can_run_ad_hoc_commands(inventory) + + diff --git a/awx/main/tests/functional/test_rbac_organization.py b/awx/main/tests/functional/test_rbac_organization.py index c8f1d709a2..cf3f5f6cdf 100644 --- a/awx/main/tests/functional/test_rbac_organization.py +++ b/awx/main/tests/functional/test_rbac_organization.py @@ -57,27 +57,27 @@ def test_organization_access_superuser(cl, organization, user): def test_organization_access_admin(cl, organization, user): '''can_change because I am an admin of that org''' a = user('admin', False) - organization.admins.add(a) - organization.users.add(user('user', False)) + organization.admin_role.members.add(a) + organization.member_role.members.add(user('user', False)) access = OrganizationAccess(a) assert access.can_change(organization, None) assert access.can_delete(organization) org = access.get_queryset()[0] - assert len(org.admins.all()) == 1 - assert len(org.users.all()) == 1 + assert len(org.admin_role.members.all()) == 1 + assert len(org.member_role.members.all()) == 1 @mock.patch.object(BaseAccess, 'check_license', return_value=None) @pytest.mark.django_db def test_organization_access_user(cl, organization, user): access = OrganizationAccess(user('user', False)) - organization.users.add(user('user', False)) + organization.member_role.members.add(user('user', False)) assert not access.can_change(organization, None) assert not access.can_delete(organization) org = access.get_queryset()[0] - assert len(org.admins.all()) == 0 - assert len(org.users.all()) == 1 + assert len(org.admin_role.members.all()) == 0 + assert len(org.member_role.members.all()) == 1 From 70335429105641ccc65902917af6f365afe8cf75 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 1 Mar 2016 15:00:36 -0500 Subject: [PATCH 101/297] Updated Invetory access --- awx/main/access.py | 56 +++++-------------- .../tests/functional/test_rbac_inventory.py | 1 + 2 files changed, 14 insertions(+), 43 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 3870b1012e..231836115e 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -294,53 +294,23 @@ class InventoryAccess(BaseAccess): 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 = self.model.accessible_objects(self.user) 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()) + return qs def can_read(self, obj): - return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ) + return obj.accessible_by(self.user, {'read': True}) 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. + return Organization.accessible_objects(self.user, ALL_PERMISSIONS).exists() 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 self.user.can_access(Organization, 'change', org, None): - return True - return False + + org_pk = get_pk_from_dict(data, 'organization') + org = get_object_or_400(Organization, pk=org_pk) + return org.accessible_by(self.user, {'read': True, 'create':True, 'update': True, 'delete': True}) def can_change(self, obj, data): # Verify that the user has access to the new organization if moving an @@ -348,10 +318,10 @@ class InventoryAccess(BaseAccess): 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 self.user.can_access(Organization, 'change', org, None): + if not org.accessible_by(self.user, {'read': True, 'create':True, 'update': True, 'delete': True}): return False # Otherwise, just check for write permission. - return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) + return obj.accessible_by(self.user, {'read': True, 'create':True, 'update': True, 'delete': True}) def can_admin(self, obj, data): # Verify that the user has access to the new organization if moving an @@ -359,16 +329,16 @@ class InventoryAccess(BaseAccess): 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 self.user.can_access(Organization, 'change', org, None): + if not org.accessible_by(self.user, ALL_PERMISSIONS): return False # Otherwise, just check for admin permission. - return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN) + return obj.accessible_by(self.user, ALL_PERMISSIONS) 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) + return obj.accessible_by(self.user, {'execute': True}) class HostAccess(BaseAccess): ''' diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index d4440f6902..3faf66fb91 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -2,6 +2,7 @@ import pytest from awx.main.migrations import _rbac as rbac from awx.main.models import Permission +from awx.main.access import InventoryAccess from django.apps import apps @pytest.mark.django_db From 2c690c82d942c56b665a09635a20ea82b7bb76fe Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 15:34:06 -0500 Subject: [PATCH 102/297] Renamed rbac migrations to be after the notification and fact migrations --- .../migrations/0005_v300_active_field_changes.py | 16 ++++++++++++++++ ...rbac_changes.py => 0006_v300_rbac_changes.py} | 2 +- ...igrations.py => 0007_v300_rbac_migrations.py} | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 awx/main/migrations/0005_v300_active_field_changes.py rename awx/main/migrations/{0003_rbac_changes.py => 0006_v300_rbac_changes.py} (99%) rename awx/main/migrations/{0004_rbac_migrations.py => 0007_v300_rbac_migrations.py} (92%) diff --git a/awx/main/migrations/0005_v300_active_field_changes.py b/awx/main/migrations/0005_v300_active_field_changes.py new file mode 100644 index 0000000000..d7582fc5fb --- /dev/null +++ b/awx/main/migrations/0005_v300_active_field_changes.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from awx.main.migrations import _rbac as rbac +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0004_v300_changes'), + ] + + operations = [ + # This is a placeholder for our future active flag removal work + ] diff --git a/awx/main/migrations/0003_rbac_changes.py b/awx/main/migrations/0006_v300_rbac_changes.py similarity index 99% rename from awx/main/migrations/0003_rbac_changes.py rename to awx/main/migrations/0006_v300_rbac_changes.py index 59468e2325..e85421573f 100644 --- a/awx/main/migrations/0003_rbac_changes.py +++ b/awx/main/migrations/0006_v300_rbac_changes.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): ('taggit', '0002_auto_20150616_2121'), ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('main', '0002_v300_changes'), + ('main', '0005_v300_active_field_changes'), ] operations = [ diff --git a/awx/main/migrations/0004_rbac_migrations.py b/awx/main/migrations/0007_v300_rbac_migrations.py similarity index 92% rename from awx/main/migrations/0004_rbac_migrations.py rename to awx/main/migrations/0007_v300_rbac_migrations.py index 62b90a6783..d50069ab48 100644 --- a/awx/main/migrations/0004_rbac_migrations.py +++ b/awx/main/migrations/0007_v300_rbac_migrations.py @@ -8,7 +8,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('main', '0003_rbac_changes'), + ('main', '0006_v300_rbac_changes'), ] operations = [ From 3db13bc33c573129e4a9a56c90cbb98fe5df0be7 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 15:34:26 -0500 Subject: [PATCH 103/297] Updated fact tests to use the divergent group fixture A group fixture was created in different ways, one on devel and one on rbac, this patch just normalizes to the one usage --- awx/main/tests/functional/conftest.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 4dfed5fe0f..01c2f000f3 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -151,18 +151,23 @@ def organizations(instance): @pytest.fixture def group(inventory): def g(name): - return Group.objects.create(inventory=inventory, name=name) + try: + return Group.objects.get(name=name, inventory=inventory) + except: + return Group.objects.create(inventory=inventory, name=name) return g @pytest.fixture def hosts(group): + group1 = group('group-1') + def rf(host_count=1): hosts = [] for i in xrange(0, host_count): - name = '%s-host-%s' % (group.name, i) - (host, created) = group.inventory.hosts.get_or_create(name=name) + name = '%s-host-%s' % (group1.name, i) + (host, created) = group1.inventory.hosts.get_or_create(name=name) if created: - group.hosts.add(host) + group1.hosts.add(host) hosts.append(host) return hosts return rf @@ -307,6 +312,8 @@ def options(): @pytest.fixture def fact_scans(group, fact_ansible_json, fact_packages_json, fact_services_json): + group1 = group('group-1') + def rf(fact_scans=1, timestamp_epoch=timezone.now()): facts_json = {} facts = [] @@ -318,7 +325,7 @@ def fact_scans(group, fact_ansible_json, fact_packages_json, fact_services_json) facts_json['services'] = fact_services_json for i in xrange(0, fact_scans): - for host in group.hosts.all(): + for host in group1.hosts.all(): for module_name in module_names: facts.append(Fact.objects.create(host=host, timestamp=timestamp_current, module=module_name, facts=facts_json[module_name])) timestamp_current += timedelta(days=1) From 444aed1ab2785360b2a89237428f629585e171a4 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 1 Mar 2016 15:37:00 -0500 Subject: [PATCH 104/297] Switch make init to use manage.py directly instead of awx-manage, saves from having to install in order to do an init --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 744b5fa305..39e76bbc4d 100644 --- a/Makefile +++ b/Makefile @@ -273,9 +273,9 @@ version_file: # Do any one-time init tasks. init: @if [ "$(VIRTUAL_ENV)" ]; then \ - awx-manage register_instance --primary --hostname=127.0.0.1; \ + $(PYTHON) manage.py register_instance --primary --hostname=127.0.0.1; \ else \ - sudo awx-manage register_instance --primary --hostname=127.0.0.1; \ + sudo $(PYTHON) manage.py register_instance --primary --hostname=127.0.0.1; \ fi # Refresh development environment after pulling new code. @@ -358,7 +358,7 @@ pylint: reports @(set -o pipefail && $@ | reports/$@.report) check: flake8 pep8 # pyflakes pylint - + # Run all API unit tests. test: py.test awx/main/tests awx/api/tests awx/fact/tests From a1cc5b06b81de0baf6f3af3cf57ae36cb84a4113 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 1 Mar 2016 17:30:32 -0500 Subject: [PATCH 105/297] Remove can_access methods and registration --- awx/main/access.py | 217 ++++++----------------------------- awx/main/migrations/_rbac.py | 21 ++-- awx/main/models/__init__.py | 1 - 3 files changed, 49 insertions(+), 190 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 231836115e..d3fd865de2 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -20,7 +20,7 @@ from awx.main.models.rbac import ALL_PERMISSIONS from awx.api.license import LicenseForbids from awx.main.task_engine import TaskSerializer -__all__ = ['get_user_queryset', 'check_user_access'] +__all__ = ['get_user_queryset'] PERMISSION_TYPES = [ PERM_INVENTORY_ADMIN, @@ -90,25 +90,6 @@ def get_user_queryset(user, model_class): 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): ''' @@ -156,7 +137,7 @@ class BaseAccess(object): return self.can_change(obj, None) else: return bool(self.can_change(obj, None) and - self.user.can_access(type(sub_obj), 'read', sub_obj)) + sub_obj.accessible_by(self.user, {'read':True})) def can_unattach(self, obj, sub_obj, relationship): return self.can_change(obj, None) @@ -358,7 +339,7 @@ class HostAccess(BaseAccess): return qs.filter(inventory_id__in=inventory_ids) def can_read(self, obj): - return obj and self.user.can_access(Inventory, 'read', obj.inventory) + return obj and obj.inventory.accessible_by(self.user, {'read':True}) def can_add(self, data): if not data or 'inventory' not in data: @@ -367,7 +348,7 @@ class HostAccess(BaseAccess): # 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 self.user.can_access(Inventory, 'change', inventory, None): + if not inventory.accessible_by(self.user, {'read':True, 'create':True}): return False # Check to see if we have enough licenses @@ -381,7 +362,7 @@ class HostAccess(BaseAccess): 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 self.user.can_access(Inventory, 'change', obj.inventory, None) + return obj and obj.inventory.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): @@ -394,7 +375,7 @@ class HostAccess(BaseAccess): return True def can_delete(self, obj): - return obj and self.user.can_access(Inventory, 'delete', obj.inventory) + return obj and obj.inventory.accessible_by(self.user, {'delete':True}) class GroupAccess(BaseAccess): ''' @@ -412,7 +393,7 @@ class GroupAccess(BaseAccess): return qs.filter(inventory_id__in=inventory_ids) def can_read(self, obj): - return obj and self.user.can_access(Inventory, 'read', obj.inventory) + return obj and obj.inventory.accessible_by(self.user, {'read':True}) def can_add(self, data): if not data or 'inventory' not in data: @@ -420,7 +401,7 @@ class GroupAccess(BaseAccess): # 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 self.user.can_access(Inventory, 'change', inventory, None) + return inventory.accessible_by(self.user, {'read':True, 'create':True}) def can_change(self, obj, data): # Prevent moving a group to a different inventory. @@ -429,7 +410,7 @@ class GroupAccess(BaseAccess): 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 self.user.can_access(Inventory, 'change', obj.inventory, None) + return obj and obj.inventory.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): @@ -453,8 +434,7 @@ class GroupAccess(BaseAccess): return True def can_delete(self, obj): - return obj and self.user.can_access(Inventory, 'delete', obj.inventory) - + return obj and obj.inventory.accessible_by(self.user, {'delete':True}) class InventorySourceAccess(BaseAccess): ''' @@ -473,9 +453,9 @@ class InventorySourceAccess(BaseAccess): def can_read(self, obj): if obj and obj.group: - return self.user.can_access(Group, 'read', obj.group) + return obj.group.accessible_by(self.user, {'read':True}) elif obj and obj.inventory: - return self.user.can_access(Inventory, 'read', obj.inventory) + return obj.inventory.accessible_by(self.user, {'read':True}) else: return False @@ -486,7 +466,7 @@ class InventorySourceAccess(BaseAccess): def can_change(self, obj, data): # Checks for admin or change permission on group. if obj and obj.group: - return self.user.can_access(Group, 'change', obj.group, None) + return obj.group.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) # Can't change inventory sources attached to only the inventory, since # these are created automatically from the management command. else: @@ -596,7 +576,7 @@ class TeamAccess(BaseAccess): else: org_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=org_pk) - if self.user.can_access(Organization, 'change', org, None): + if org.accessible_by(self.user, {'read':True, 'update':True, 'write':True}): return True return False @@ -701,100 +681,7 @@ class ProjectUpdateAccess(BaseAccess): return self.can_change(obj, {}) and obj.can_cancel def can_delete(self, obj): - return obj and self.user.can_access(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 self.user.can_access(User, 'admin', user, None): - return False - elif team_pk: - team = get_object_or_400(Team, pk=team_pk) - if not self.user.can_access(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 self.user.can_access(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 self.user.can_access(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 self.user.can_access(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 self.user.can_access(Project, 'admin', project, None): - return False - # Check for admin access to the user or team. - if obj.user and self.user.can_access(User, 'admin', obj.user, None): - return True - if obj.team and self.user.can_access(Team, 'admin', obj.team, None): - return True - return False - - def can_delete(self, obj): - return self.can_change(obj, None) + return obj and obj.project.accessible_by(self.user, {'delete':True}) class JobTemplateAccess(BaseAccess): ''' @@ -895,7 +782,7 @@ class JobTemplateAccess(BaseAccess): credential_pk = get_pk_from_dict(data, 'credential') if credential_pk: credential = get_object_or_400(Credential, pk=credential_pk) - if not self.user.can_access(Credential, 'read', credential): + if not credential.accessible_by(self.user, {'read':True}): return False # If a cloud credential is provided, the user should have read access. @@ -903,7 +790,7 @@ class JobTemplateAccess(BaseAccess): if cloud_credential_pk: cloud_credential = get_object_or_400(Credential, pk=cloud_credential_pk) - if not self.user.can_access(Credential, 'read', cloud_credential): + if not cloud_credential.accessible_by(self.user, {'read':True}): return False # Check that the given inventory ID is valid. @@ -914,48 +801,19 @@ class JobTemplateAccess(BaseAccess): 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 self.user.can_access(Organization, 'change', inventory[0].organization, None): + org = inventory[0].organization + accessible = org.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) + if not project_pk and accessible: return True - elif not self.user.can_access(Organization, "change", inventory[0].organization, None): + elif not accessible: 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 self.user.can_access(Project, 'admin', project, None): + if project.accessible_by(self.user, ALL_PERMISSIONS): 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 + return project.accessible_by(self.user, ALL_PERMISSIONS) and inventory.accessible_by(self.user, {'read':True}) def can_start(self, obj, validate_license=True): # Check license. @@ -973,14 +831,14 @@ class JobTemplateAccess(BaseAccess): if obj.inventory is None: return False if obj.job_type == PERM_INVENTORY_SCAN: - if obj.project is None and self.user.can_access(Organization, 'change', obj.inventory.organization, None): + if obj.project is None and obj.inventory.organization.accessible_by(self.user, {'read':True, 'update':True, 'write':True}): return True - if not self.user.can_access(Organization, 'change', obj.inventory.organization, None): + if not obj.inventory.organization.accessible_by(self.user, {'read':True, 'update':True, 'write':True}): return False if obj.project is None: return False # If the user has admin access to the project they can start a job - if self.user.can_access(Project, 'admin', obj.project, None): + if obj.project.accessible_by(self.user, ALL_PERMISSIONS): return True # Otherwise check for explicitly granted permissions @@ -1004,7 +862,7 @@ class JobTemplateAccess(BaseAccess): obj.job_type == PERM_INVENTORY_CHECK: has_perm = True - dep_access = self.user.can_access(Inventory, 'read', obj.inventory) and self.user.can_access(Project, 'read', obj.project) + dep_access = obj.inventory.accessible_by(self.user, {'read':True}) and obj.project.accessible_by(self.user, {'read':True}) return dep_access and has_perm def can_change(self, obj, data): @@ -1119,10 +977,10 @@ class JobAccess(BaseAccess): # 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 self.user.can_access(JobTemplate, 'start', obj.job_template): + if obj.job_template is not None and obj.job_template.accessible_by(self.user, {'execute':True}): has_perm = True - dep_access_inventory = self.user.can_access(Inventory, 'read', obj.inventory) - dep_access_project = obj.project is None or self.user.can_access(Project, 'read', obj.project) + dep_access_inventory = obj.inventory.accessible_by(self.user, {'read':True}) + dep_access_project = obj.project is None or obj.project.accessible_by(self.user, {'read':True}) return self.can_read(obj) and dep_access_inventory and dep_access_project and has_perm def can_cancel(self, obj): @@ -1192,7 +1050,7 @@ class AdHocCommandAccess(BaseAccess): credential_pk = get_pk_from_dict(data, 'credential') if credential_pk: credential = get_object_or_400(Credential, pk=credential_pk, active=True) - if not self.user.can_access(Credential, 'read', credential): + if not credential.accessible_by(self.user, {'read':True}): return False # Check that the user has the run ad hoc command permission on the @@ -1200,7 +1058,7 @@ class AdHocCommandAccess(BaseAccess): inventory_pk = get_pk_from_dict(data, 'inventory') if inventory_pk: inventory = get_object_or_400(Inventory, pk=inventory_pk, active=True) - if not self.user.can_access(Inventory, 'run_ad_hoc_commands', inventory): + if not inventory.accessible_by(self.user, {'execute': True}): return False return True @@ -1403,8 +1261,7 @@ class ScheduleAccess(BaseAccess): if self.user.is_superuser: return True if obj and obj.unified_job_template: - job_class = obj.unified_job_template - return self.user.can_access(type(job_class), 'read', obj.unified_job_template) + return obj.unified_job_template.accessible_by(self.user, {'read':True}) else: return False @@ -1414,7 +1271,7 @@ class ScheduleAccess(BaseAccess): pk = get_pk_from_dict(data, 'unified_job_template') obj = get_object_or_400(UnifiedJobTemplate, pk=pk) if obj: - return self.user.can_access(type(obj), 'change', obj, None) + return obj.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) else: return False @@ -1422,8 +1279,7 @@ class ScheduleAccess(BaseAccess): if self.user.is_superuser: return True if obj and obj.unified_job_template: - job_class = obj.unified_job_template - return self.user.can_access(type(job_class), 'change', job_class, None) + return obj.unified_job_template.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) else: return False @@ -1431,8 +1287,7 @@ class ScheduleAccess(BaseAccess): if self.user.is_superuser: return True if obj and obj.unified_job_template: - job_class = obj.unified_job_template - return self.user.can_access(type(job_class), 'change', job_class, None) + return obj.unified_job_template.accessible_by(self.user, {'read':True, 'update':True, 'write':True}) else: return False diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 546721c4f6..f26970f9ba 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -195,19 +195,24 @@ def migrate_job_templates(apps, schema_editor): Permission = apps.get_model('main', 'Permission') for jt in JobTemplate.objects.all(): + permission = Permission.objects.filter( + inventory=jt.inventory, + project=jt.project, + active=True, + permission_type__in=['create', 'check', 'run'] if jt.job_type == 'check' else ['create', 'run'], + ) + 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); + if permission.filter(team=team).exists(): + team.member_role.children.add(jt.executor_role) migrations[jt.name]['teams'].add(team) for user in User.objects.all(): + if permission.filter(user=user).exists(): + jt.exector_role.members.add(user) + migrations[jt.name]['users'].add(user) + 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 diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 85476a19e7..41b866f78c 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -37,7 +37,6 @@ _PythonSerializer.handle_m2m_field = _new_handle_m2m_field from django.contrib.auth.models import User # noqa from awx.main.access import * # noqa User.add_to_class('get_queryset', get_user_queryset) -User.add_to_class('can_access', check_user_access) # Import signal handlers only after models have been defined. import awx.main.signals # noqa From 9699f3497611669001f33c647fdcb957edd4c350 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 2 Mar 2016 09:44:55 -0500 Subject: [PATCH 106/297] Made org admin role a parent of org member role so admins pick up everything members are granted --- awx/main/models/organization.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 89b61f4fee..025ac49c6c 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -61,6 +61,7 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin): ) member_role = ImplicitRoleField( role_name='Organization Member', + parent_role='admin_role', permissions = {'read': True} ) From c15d48a640be701d3876992552549f84bfbef37f Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 2 Mar 2016 16:36:16 -0500 Subject: [PATCH 107/297] Locked down user/team role listing and role membership management api endpoints --- awx/api/views.py | 44 +++-- awx/main/access.py | 18 +- awx/main/models/rbac.py | 5 + awx/main/tests/functional/test_rbac_api.py | 216 +++++++++++++++------ 4 files changed, 198 insertions(+), 85 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 45c854b5f3..2a9c2b7228 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -730,11 +730,8 @@ class TeamRolesList(SubListCreateAttachDetachAPIView): relationship='member_role.children' 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. team = Team.objects.get(pk=self.kwargs['pk']) - return team.member_role.children + return team.member_role.children.filter(id__in=Role.visible_roles(self.request.user)) # XXX: Need to enforce permissions def post(self, request, *args, **kwargs): @@ -979,13 +976,11 @@ class UserRolesList(SubListCreateAttachDetachAPIView): serializer_class = RoleSerializer parent_model = User relationship='roles' + permission_classes = (IsAuthenticated,) 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 + #u = User.objects.get(pk=self.kwargs['pk']) + return Role.visible_roles(self.request.user).filter(members__in=[int(self.kwargs['pk']), ]) def post(self, request, *args, **kwargs): # Forbid implicit role creation here @@ -995,6 +990,10 @@ class UserRolesList(SubListCreateAttachDetachAPIView): return Response(data, status=status.HTTP_400_BAD_REQUEST) return super(type(self), self).post(request, *args, **kwargs) + def check_parent_access(self, parent=None): + # We hide roles that shouldn't be seen in our queryset + return True + class UserProjectsList(SubListAPIView): @@ -3162,29 +3161,27 @@ 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 + permission_classes = (IsAuthenticated,) 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 + if self.request.user.is_superuser: + return Role.objects + return Role.visible_roles(self.request.user) - # 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): +class RoleDetail(RetrieveAPIView): model = Role serializer_class = RoleSerializer + permission_classes = (IsAuthenticated,) new_in_300 = True - # XXX: Permissions - only appropriate people should be able to change these - class RoleUsersList(SubListCreateAttachDetachAPIView): @@ -3192,6 +3189,8 @@ class RoleUsersList(SubListCreateAttachDetachAPIView): serializer_class = UserSerializer parent_model = Role relationship = 'members' + permission_classes = (IsAuthenticated,) + new_in_300 = True def get_queryset(self): # XXX: Access control @@ -3213,6 +3212,8 @@ class RoleTeamsList(ListAPIView): serializer_class = TeamSerializer parent_model = Role relationship = 'member_role.parents' + permission_classes = (IsAuthenticated,) + new_in_300 = True def get_queryset(self): # TODO: Check @@ -3243,6 +3244,8 @@ class RoleParentsList(SubListAPIView): serializer_class = RoleSerializer parent_model = Role relationship = 'parents' + permission_classes = (IsAuthenticated,) + new_in_300 = True def get_queryset(self): # XXX: This should be the intersection between the roles of the user @@ -3256,6 +3259,8 @@ class RoleChildrenList(SubListAPIView): serializer_class = RoleSerializer parent_model = Role relationship = 'children' + permission_classes = (IsAuthenticated,) + new_in_300 = True def get_queryset(self): # XXX: This should be the intersection between the roles of the user @@ -3267,6 +3272,7 @@ class ResourceDetail(RetrieveAPIView): model = Resource serializer_class = ResourceSerializer + permission_classes = (IsAuthenticated,) new_in_300 = True # XXX: Permissions - only roles the user has access to see should be listed here @@ -3277,6 +3283,7 @@ class ResourceList(ListAPIView): model = Resource serializer_class = ResourceSerializer + permission_classes = (IsAuthenticated,) new_in_300 = True def get_queryset(self): @@ -3286,6 +3293,7 @@ class ResourceAccessList(ListAPIView): model = User serializer_class = ResourceAccessListElementSerializer + permission_classes = (IsAuthenticated,) new_in_300 = True def get_queryset(self): diff --git a/awx/main/access.py b/awx/main/access.py index 84eb3957a9..96f632e832 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1695,23 +1695,31 @@ class RoleAccess(BaseAccess): def get_queryset(self): if self.user.is_superuser: return self.model.objects.all() - return self.model.objects.none() + return self.model.visible_roles(self.user) def can_change(self, obj, data): return self.user.is_superuser def can_add(self, obj, data): - return self.user.is_superuser + # Unsupported for now + return False def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): - return self.user.is_superuser + return self.can_unattach(obj, sub_obj, relationship) def can_unattach(self, obj, sub_obj, relationship): - return self.user.is_superuser + if self.user.is_superuser: + return True + if obj.object_id and \ + isinstance(obj.content_object, ResourceMixin) and \ + obj.content_object.accessible_by(self.user, {'write': True}): + return True + return False def can_delete(self, obj): - return self.user.is_superuser + # Unsupported for now + return False class ResourceAccess(BaseAccess): diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 396dcd71c3..0b2fb64290 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -6,6 +6,7 @@ import logging # Django from django.db import models +from django.db.models import Q from django.db.models.aggregates import Max from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ @@ -139,6 +140,10 @@ class Role(CommonModelNameNotUnique): setattr(permission, k, int(permissions[k])) permission.save() + @staticmethod + def visible_roles(user): + return Role.objects.filter(Q(descendents__in=user.roles.filter()) | Q(ancestors__in=user.roles.filter())) + @staticmethod def singleton(name): try: diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index 0cb3166e7c..c99c49aad3 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -2,7 +2,7 @@ import mock # noqa import pytest from django.core.urlresolvers import reverse -from awx.main.models.rbac import Role +from awx.main.models.rbac import Role, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR def mock_feature_enabled(feature, bypass_database=None): return True @@ -24,39 +24,55 @@ def test_get_roles_list_admin(organization, get, admin): assert roles['count'] > 0 @pytest.mark.django_db -@pytest.mark.skipif(True, reason='Unimplemented') -def test_get_roles_list_user(organization, get, user): +def test_get_roles_list_user(organization, inventory, team, get, user): 'Users can see all roles they have access to, but not all roles' - assert False + this_user = user('user-test_get_roles_list_user') + organization.member_role.members.add(this_user) + custom_role = Role.objects.create(name='custom_role-test_get_roles_list_user') + organization.member_role.children.add(custom_role) + + url = reverse('api:role_list') + response = get(url, this_user) + assert response.status_code == 200 + roles = response.data + assert roles['count'] > 0 + assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid + + role_hash = {} + + for r in roles['results']: + role_hash[r['id']] = r + + assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id in role_hash + assert organization.admin_role.id in role_hash + assert organization.member_role.id in role_hash + assert this_user.resource.admin_role.id in role_hash + assert custom_role.id in role_hash + + assert inventory.admin_role.id not in role_hash + assert team.member_role.id not in role_hash + + @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) +def test_cant_create_role(post, admin): + "Ensure we can't create new roles through the api" + # Some day we might want to do this, but until that is speced out, lets + # ensure we don't slip up and allow this implicitly through some helper or + # another response = post(reverse('api:role_list'), {'name': 'New Role'}, admin) - assert response.status_code == 201 + assert response.status_code == 405 @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 +def test_cant_delete_role(delete, admin): + "Ensure we can't delete roles through the api" + # Some day we might want to do this, but until that is speced out, lets + # ensure we don't slip up and allow this implicitly through some helper or + # another + response = delete(reverse('api:role_detail', args=(admin.resource.admin_role.id,)), admin) + assert response.status_code == 405 @@ -72,6 +88,53 @@ def test_get_user_roles_list(get, admin): roles = response.data assert roles['count'] > 0 # 'System Administrator' role if nothing else +@pytest.mark.django_db +def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob): + 'Users can see roles for other users, but only the roles that that user has access to see as well' + organization.member_role.members.add(alice) + organization.admins.add(bob) + custom_role = Role.objects.create(name='custom_role-test_user_view_admin_roles_list') + organization.member_role.children.add(custom_role) + team.users.add(bob) + + # alice and bob are in the same org and can see some child role of that org. + # Bob is an org admin, alice can see this. + # Bob is in a team that alice is not, alice cannot see that bob is a member of that team. + + url = reverse('api:user_roles_list', args=(bob.id,)) + response = get(url, alice) + assert response.status_code == 200 + roles = response.data + assert roles['count'] > 0 + assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid + + role_hash = {} + for r in roles['results']: + role_hash[r['id']] = r['name'] + + assert organization.admin_role.id in role_hash + assert custom_role.id not in role_hash # doesn't show up in the user roles list, not an explicit grant + assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id not in role_hash + assert inventory.admin_role.id not in role_hash + assert team.member_role.id not in role_hash # alice can't see this + + # again but this time alice is part of the team, and should be able to see the team role + team.users.add(alice) + response = get(url, alice) + assert response.status_code == 200 + roles = response.data + assert roles['count'] > 0 + assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid + + role_hash = {} + for r in roles['results']: + role_hash[r['id']] = r['name'] + + assert team.member_role.id in role_hash # Alice can now see this + + + + @pytest.mark.django_db def test_add_role_to_user(role, post, admin): assert admin.roles.filter(id=role.id).count() == 0 @@ -165,15 +228,15 @@ def test_get_role(get, admin, role): 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' + assert response.status_code == 405 + #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 + assert response.status_code == 403 or response.status_code == 405 # @@ -204,6 +267,67 @@ def test_remove_user_to_role(post, admin, role): post(url, {'disassociate': True, 'id': admin.id}, admin) assert role.members.filter(id=admin.id).count() == 0 +@pytest.mark.django_db +def test_org_admin_add_user_to_job_template(post, organization, check_jobtemplate, user): + 'Tests that a user with permissions to assign/revoke membership to a particular role can do so' + org_admin = user('org-admin') + joe = user('joe') + organization.admins.add(org_admin) + + assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + + post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, org_admin) + + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + +@pytest.mark.django_db +def test_org_admin_remove_user_to_job_template(post, organization, check_jobtemplate, user): + 'Tests that a user with permissions to assign/revoke membership to a particular role can do so' + org_admin = user('org-admin') + joe = user('joe') + organization.admins.add(org_admin) + check_jobtemplate.executor_role.members.add(joe) + + assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'disassociate': True, 'id': joe.id}, org_admin) + + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + +@pytest.mark.django_db +def test_user_fail_to_add_user_to_job_template(post, organization, check_jobtemplate, user): + 'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so' + rando = user('rando') + joe = user('joe') + + assert check_jobtemplate.accessible_by(rando, {'write': True}) is False + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + + res = post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, rando) + assert res.status_code == 403 + + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False + + +@pytest.mark.django_db +def test_user_fail_to_remove_user_to_job_template(post, organization, check_jobtemplate, user): + 'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so' + rando = user('rando') + joe = user('joe') + check_jobtemplate.executor_role.members.add(joe) + + assert check_jobtemplate.accessible_by(rando, {'write': True}) is False + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + res = post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'disassociate': True, 'id': joe.id}, rando) + assert res.status_code == 403 + + assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True + + # # /roles//teams/ # @@ -252,22 +376,6 @@ def test_role_parents(get, team, admin, role): 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/ @@ -282,22 +390,6 @@ def test_role_children(get, team, admin, role): 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 From 048e65eab3c3b1c659a4cb292f208f9a724021b1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 3 Mar 2016 13:54:45 -0500 Subject: [PATCH 108/297] Add test to help detect incorrect role rebuilding --- awx/main/tests/functional/test_rbac_core.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py index 020023f9bd..deae21b3b8 100644 --- a/awx/main/tests/functional/test_rbac_core.py +++ b/awx/main/tests/functional/test_rbac_core.py @@ -138,3 +138,32 @@ def test_content_object(user): assert org.resource.content_object.id == org.id assert org.admin_role.content_object.id == org.id +@pytest.mark.django_db +def test_hierarchy_rebuilding(): + 'Tests some subdtle cases around role hierarchy rebuilding' + + X = Role.objects.create(name='X') + A = Role.objects.create(name='A') + B = Role.objects.create(name='B') + C = Role.objects.create(name='C') + D = Role.objects.create(name='D') + + A.children.add(B) + A.children.add(D) + B.children.add(C) + C.children.add(D) + + assert A.is_ancestor_of(D) + assert X.is_ancestor_of(D) is False + + X.children.add(A) + + assert X.is_ancestor_of(D) is True + + X.children.remove(A) + + # This can be the stickler, the rebuilder needs to ensure that D's role + # hierarchy is built after both A and C are updated. + assert X.is_ancestor_of(D) is False + + From db6117a56d448b9332660a7ccabb58704f19a20b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 3 Mar 2016 16:18:28 -0500 Subject: [PATCH 109/297] Added role description fields Completes #1096 --- awx/api/serializers.py | 3 ++- awx/main/fields.py | 9 ++++++--- awx/main/models/credential.py | 2 ++ awx/main/models/inventory.py | 4 ++++ awx/main/models/jobs.py | 5 ++++- awx/main/models/organization.py | 6 ++++++ awx/main/models/projects.py | 4 ++++ awx/main/models/user.py | 1 + 8 files changed, 29 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d6e47e129c..b1f11268eb 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -358,6 +358,7 @@ class BaseSerializer(serializers.ModelSerializer): roles[field.name] = { 'id': role.id, 'name': role.name, + 'description': role.description, 'url': role.get_absolute_url(), } if len(roles) > 0: @@ -1540,7 +1541,7 @@ class ResourceAccessListElementSerializer(UserSerializer): ret['summary_fields']['permissions'] = resource.get_permissions(user) def format_role_perm(role): - role_dict = { 'id': role.id, 'name': role.name} + role_dict = { 'id': role.id, 'name': role.name, 'description': role.description} try: role_dict['resource_name'] = role.content_object.name role_dict['resource_type'] = role.content_type.name diff --git a/awx/main/fields.py b/awx/main/fields.py index 1db59e296f..b3efcd20e6 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -134,8 +134,9 @@ def resolve_role_field(obj, field): class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): """Descriptor Implict Role Fields. Auto-creates the appropriate role entry on first access""" - def __init__(self, role_name, permissions, parent_role, *args, **kwargs): + def __init__(self, role_name, role_description, permissions, parent_role, *args, **kwargs): self.role_name = role_name + self.role_description = role_description if role_description else "" self.permissions = permissions self.parent_role = parent_role @@ -152,7 +153,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if connection.needs_rollback: raise TransactionManagementError('Current transaction has failed, cannot create implicit role') - role = Role.objects.create(name=self.role_name, content_object=instance) + role = Role.objects.create(name=self.role_name, description=self.role_description, content_object=instance) if self.parent_role: # Add all non-null parent roles as parents @@ -195,8 +196,9 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): class ImplicitRoleField(models.ForeignKey): """Implicitly creates a role entry for a resource""" - def __init__(self, role_name=None, permissions=None, parent_role=None, *args, **kwargs): + def __init__(self, role_name=None, role_description=None, permissions=None, parent_role=None, *args, **kwargs): self.role_name = role_name + self.role_description = role_description self.permissions = permissions self.parent_role = parent_role @@ -211,6 +213,7 @@ class ImplicitRoleField(models.ForeignKey): self.name, ImplicitRoleDescriptor( self.role_name, + self.role_description, self.permissions, self.parent_role, self diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index cf2dd262ed..ec47cb1fbb 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -157,11 +157,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): ) owner_role = ImplicitRoleField( role_name='Credential Owner', + role_description='Owner of the credential', parent_role='team.admin_role', permissions = {'all': True} ) usage_role = ImplicitRoleField( role_name='Credential User', + role_description='May use this credential, but not read sensitive portions or modify it', parent_role= 'team.member_role', permissions = {'use': True} ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index c289827400..32175b19d9 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -98,19 +98,23 @@ class Inventory(CommonModel, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Inventory Administrator', + role_description='May manage this inventory', parent_role='organization.admin_role', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Inventory Auditor', + role_description='May view but not modify this inventory', parent_role='organization.auditor_role', permissions = {'read': True} ) updater_role = ImplicitRoleField( role_name='Inventory Updater', + role_description='May update the inventory', ) executor_role = ImplicitRoleField( role_name='Inventory Executor', + role_description='May execute jobs against this inventory', ) def get_absolute_url(self): diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 1db5faa2b1..ba0170bf69 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -185,16 +185,19 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Job Template Administrator', + role_description='Full access to all settings', parent_role='project.admin_role', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Job Template Auditor', + role_description='Read-only access to all settings', parent_role='project.auditor_role', permissions = {'read': True} ) executor_role = ImplicitRoleField( - role_name='Job Template Executor', + role_name='Job Template Runner', + role_description='May run the job template', permissions = {'read': True, 'execute': True} ) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 025ac49c6c..f04ee7ea1d 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -51,16 +51,19 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Organization Administrator', + role_description='May manage all aspects of this organization', parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Organization Auditor', + role_description='May read all settings associated with this organization', parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Organization Member', + role_description='A member of this organization', parent_role='admin_role', permissions = {'read': True} ) @@ -108,16 +111,19 @@ class Team(CommonModelNameNotUnique, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Team Administrator', + role_description='May manage this team', parent_role='organization.admin_role', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Team Auditor', + role_description='May read all settings associated with this team', parent_role='organization.auditor_role', permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Team Member', + role_description='A member of this team', parent_role='admin_role', permissions = {'read':True}, ) diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index cf7f269e63..4bb66c24d6 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -211,20 +211,24 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): ) admin_role = ImplicitRoleField( role_name='Project Administrator', + role_description='May manage this project', parent_role='organizations.admin_role', permissions = {'all': True} ) auditor_role = ImplicitRoleField( role_name='Project Auditor', + role_description='May read all settings associated with this project', parent_role='organizations.auditor_role', permissions = {'read': True} ) member_role = ImplicitRoleField( role_name='Project Member', + role_description='Implies membership within this project', permissions = {'read': True} ) scm_update_role = ImplicitRoleField( role_name='Project Updater', + role_description='May update this project from the source control management system', parent_role='admin_role', permissions = {'scm_update': True} ) diff --git a/awx/main/models/user.py b/awx/main/models/user.py index c30696bdb1..fad82ba182 100644 --- a/awx/main/models/user.py +++ b/awx/main/models/user.py @@ -26,5 +26,6 @@ class UserResource(CommonModelNameNotUnique, ResourceMixin): admin_role = ImplicitRoleField( role_name='User Administrator', + role_description='May manage this user', permissions = {'all': True}, ) From fd2212dd33f38111204d1fb073003e318eac1c85 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 8 Mar 2016 13:47:11 -0500 Subject: [PATCH 110/297] More updates to access, use RBAC for permission checks --- awx/main/access.py | 269 ++++++++++----------------------------------- 1 file changed, 57 insertions(+), 212 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 092652acc2..c8d0ea5047 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -330,13 +330,12 @@ class HostAccess(BaseAccess): model = Host def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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) + return qs def can_read(self, obj): return obj and obj.inventory.accessible_by(self.user, {'read':True}) @@ -386,11 +385,10 @@ class GroupAccess(BaseAccess): model = Group def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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) + return qs def can_read(self, obj): return obj and obj.inventory.accessible_by(self.user, {'read':True}) @@ -417,9 +415,6 @@ class GroupAccess(BaseAccess): 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') @@ -445,11 +440,9 @@ class InventorySourceAccess(BaseAccess): model = InventorySource def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_by(self.user, {'read':True}) 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)) + return qs def can_read(self, obj): if obj and obj.group: @@ -561,14 +554,9 @@ class TeamAccess(BaseAccess): model = Team def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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]) - ) + return qs def can_add(self, data): if self.user.is_superuser: @@ -585,11 +573,7 @@ class TeamAccess(BaseAccess): 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 + return obj.organization.accessible_by(self.user, ALL_PERMISSIONS) def can_delete(self, obj): return self.can_change(obj, None) @@ -613,48 +597,20 @@ class ProjectAccess(BaseAccess): model = Project def get_queryset(self): - qs = Project.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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 + return 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 + qs = Organization.accessible_objects(self.uesr, ALL_PERMISSIONS) + return bool(qs.count() > 0) 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 + return obj.accessible_by(self.user, ALL_PERMISSIONS) def can_delete(self, obj): return self.can_change(obj, None) @@ -699,60 +655,10 @@ class JobTemplateAccess(BaseAccess): model = JobTemplate def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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) - ) + return qs def can_read(self, obj): # you can only see the job templates that you have permission to launch. @@ -841,29 +747,7 @@ class JobTemplateAccess(BaseAccess): if obj.project.accessible_by(self.user, ALL_PERMISSIONS): 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 = obj.inventory.accessible_by(self.user, {'read':True}) and obj.project.accessible_by(self.user, {'read':True}) - return dep_access and has_perm + return obj.inventory.accessible_by(self.user, {'read':True}) and obj.project.accessible_by(self.user, {'read':True}) def can_change(self, obj, data): data_for_change = data @@ -1115,7 +999,7 @@ class JobHostSummaryAccess(BaseAccess): model = JobHostSummary def get_queryset(self): - qs = self.model.objects.distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) qs = qs.select_related('job', 'job__job_template', 'host') if self.user.is_superuser: return qs @@ -1140,7 +1024,7 @@ class JobEventAccess(BaseAccess): model = JobEvent def get_queryset(self): - qs = self.model.objects.distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) qs = qs.select_related('job', 'job__job_template', 'host', 'parent') qs = qs.prefetch_related('hosts', 'children') @@ -1177,7 +1061,7 @@ class UnifiedJobTemplateAccess(BaseAccess): model = UnifiedJobTemplate def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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) @@ -1187,15 +1071,17 @@ class UnifiedJobTemplateAccess(BaseAccess): 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. + qs = qs.prefetch_related( + 'project', + 'inventory', + 'credential', + 'cloud_credential', + ) + return qs class UnifiedJobAccess(BaseAccess): @@ -1207,7 +1093,7 @@ class UnifiedJobAccess(BaseAccess): model = UnifiedJob def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) 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) @@ -1221,19 +1107,23 @@ class UnifiedJobAccess(BaseAccess): 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. + qs = qs.prefetch_related( + 'unified_job_template', + 'project', + 'inventory', + 'credential', + 'job_template', + 'inventory_source', + 'cloud_credential', + 'project___credential', + 'inventory_source___credential', + 'inventory_source___inventory', + 'job_template__inventory', + 'job_template__project', + 'job_template__credential', + 'job_template__cloud_credential', + ) return qs class ScheduleAccess(BaseAccess): @@ -1244,7 +1134,7 @@ class ScheduleAccess(BaseAccess): model = Schedule def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.objects.all() qs = qs.select_related('created_by', 'modified_by') qs = qs.prefetch_related('unified_job_template') if self.user.is_superuser: @@ -1298,7 +1188,7 @@ class NotifierAccess(BaseAccess): model = Notifier def get_queryset(self): - qs = self.model.objects.filter(active=True).distinct() + qs = self.model.objects.distinct() if self.user.is_superuser: return qs return qs @@ -1324,7 +1214,7 @@ class ActivityStreamAccess(BaseAccess): model = ActivityStream def get_queryset(self): - qs = self.model.objects.distinct() + qs = self.model.accessible_objects(self.user, {'read':True}) qs = qs.select_related('actor') qs = qs.prefetch_related('organization', 'user', 'inventory', 'host', 'group', 'inventory_source', 'inventory_update', 'credential', 'team', 'project', 'project_update', @@ -1332,17 +1222,6 @@ class ActivityStreamAccess(BaseAccess): 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) @@ -1362,15 +1241,12 @@ class ActivityStreamAccess(BaseAccess): 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])) + credential_qs = self.user.get_queryset(Credential) + qs.filter(credential__in=credential_qs) #Team Filter - qs.filter(Q(team__organization__admins__in=[self.user]) | - Q(team__users__in=[self.user])) + team_qs = self.user.get_queryset(Team) + qs.filter(team__in=team_qs) #Project Filter project_qs = self.user.get_queryset(Project) @@ -1395,33 +1271,6 @@ class ActivityStreamAccess(BaseAccess): 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): @@ -1438,17 +1287,14 @@ 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 + if self.user.is_superuser: + return self.model.objects.distinct() + return self.model.accessible_by(self.user, {'read':True}) 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()) + return obj.accessible_by(self.user, {'read':True}) def can_add(self, data): if self.user.is_superuser: @@ -1500,7 +1346,7 @@ class RoleAccess(BaseAccess): def get_queryset(self): if self.user.is_superuser: return self.model.objects.all() - return self.model.visible_roles(self.user) + return self.model.accessible_objects(self.user, {'read':True}) def can_change(self, obj, data): return self.user.is_superuser @@ -1519,7 +1365,7 @@ class RoleAccess(BaseAccess): if obj.object_id and \ isinstance(obj.content_object, ResourceMixin) and \ obj.content_object.accessible_by(self.user, {'write': True}): - return True + return True return False def can_delete(self, obj): @@ -1566,7 +1412,6 @@ 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) From 3df4c4ec9318f080ba5eaffd884fb688dfa69461 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 8 Mar 2016 14:09:16 -0500 Subject: [PATCH 111/297] fixing tests and migration issues --- awx/main/migrations/_rbac.py | 2 +- awx/main/tests/functional/test_rbac_team.py | 18 +++++++++--------- .../tests/functional/test_rbac_userresource.py | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index f26970f9ba..2cf0b6e775 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -210,7 +210,7 @@ def migrate_job_templates(apps, schema_editor): for user in User.objects.all(): if permission.filter(user=user).exists(): - jt.exector_role.members.add(user) + jt.executor_role.members.add(user) migrations[jt.name]['users'].add(user) if jt.accessible_by(user, {'execute': True}): diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index ad10351fa9..4ba4218c01 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -22,7 +22,7 @@ def test_team_migration_user(team, user, permissions): @pytest.mark.django_db def test_team_access_superuser(team, user): - team.users.add(user('member', False)) + team.member_role.members.add(user('member', False)) access = TeamAccess(user('admin', True)) @@ -31,13 +31,13 @@ def test_team_access_superuser(team, user): assert access.can_delete(team) t = access.get_queryset()[0] - assert len(t.users.all()) == 1 - assert len(t.organization.admins.all()) == 0 + assert len(t.member_role.members.all()) == 1 + assert len(t.organization.admin_role.members.all()) == 0 @pytest.mark.django_db def test_team_access_org_admin(organization, team, user): a = user('admin', False) - organization.admins.add(a) + organization.admin_role.members.add(a) team.organization = organization team.save() @@ -47,13 +47,13 @@ def test_team_access_org_admin(organization, team, user): assert access.can_delete(team) t = access.get_queryset()[0] - assert len(t.users.all()) == 0 - assert len(t.organization.admins.all()) == 1 + assert len(t.member_role.members.all()) == 0 + assert len(t.organization.admin_role.members.all()) == 1 @pytest.mark.django_db def test_team_access_member(organization, team, user): u = user('member', False) - team.users.add(u) + team.member_role.members.add(u) team.organization = organization team.save() @@ -63,6 +63,6 @@ def test_team_access_member(organization, team, user): assert not access.can_delete(team) t = access.get_queryset()[0] - assert len(t.users.all()) == 1 - assert len(t.organization.admins.all()) == 0 + assert len(t.member_role.members.all()) == 1 + assert len(t.organization.admin_role.members.all()) == 0 diff --git a/awx/main/tests/functional/test_rbac_userresource.py b/awx/main/tests/functional/test_rbac_userresource.py index f5e83438df..517b297298 100644 --- a/awx/main/tests/functional/test_rbac_userresource.py +++ b/awx/main/tests/functional/test_rbac_userresource.py @@ -19,13 +19,13 @@ def test_org_user_admin(user, organization): admin = user('orgadmin') member = user('orgmember') - organization.users.add(member) + organization.member_role.members.add(member) assert not member.resource.accessible_by(admin, {'write':True}) - organization.admins.add(admin) + organization.admin_role.members.add(admin) assert member.resource.accessible_by(admin, {'write':True}) - organization.admins.remove(admin) + organization.admin_role.members.remove(admin) assert not member.resource.accessible_by(admin, {'write':True}) @pytest.mark.django_db @@ -33,10 +33,10 @@ def test_org_user_removed(user, organization): admin = user('orgadmin') member = user('orgmember') - organization.admins.add(admin) - organization.users.add(member) + organization.admin_role.members.add(admin) + organization.member_role.members.add(member) assert member.resource.accessible_by(admin, {'write':True}) - organization.users.remove(member) + organization.member_role.members.remove(member) assert not member.resource.accessible_by(admin, {'write':True}) From 9aae2979d9a62c8db4868bb14a76caf8b1173ade Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 10:12:05 -0500 Subject: [PATCH 112/297] Replaced our 'Resource' table with a GenericForeignKey in RolePermission --- awx/api/serializers.py | 26 +-- awx/api/urls.py | 6 +- awx/api/views.py | 14 +- awx/main/access.py | 29 --- awx/main/fields.py | 115 +++--------- awx/main/migrations/0006_v300_rbac_changes.py | 120 +++---------- awx/main/models/mixins.py | 49 +++-- awx/main/models/rbac.py | 170 ++++++++---------- awx/main/tests/functional/test_rbac_api.py | 14 +- awx/main/tests/functional/test_rbac_core.py | 43 +++-- 10 files changed, 239 insertions(+), 347 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 71a103a1a6..80defe4d8f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -16,6 +16,7 @@ import yaml from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError from django.db import models @@ -293,8 +294,8 @@ class BaseSerializer(serializers.ModelSerializer): 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,)) - res['resource_access_list'] = reverse('api:resource_access_list', args=(obj.resource_id,)) + content_type_id = ContentType.objects.get_for_model(obj).pk + res['resource_access_list'] = reverse('api:resource_access_list', kwargs={'content_type_id': content_type_id, 'pk': obj.pk}) return res def _get_summary_fields(self, obj): @@ -366,8 +367,8 @@ class BaseSerializer(serializers.ModelSerializer): return summary_fields def get_resource_id(self, obj): - if isinstance(obj, ResourceMixin): - return obj.resource.id + content_type_id = ContentType.objects.get_for_model(obj).pk + return '%d/%d' % (content_type_id, obj.pk) return None def get_created(self, obj): @@ -1508,6 +1509,7 @@ class RoleSerializer(BaseSerializer): return ret +""" class ResourceSerializer(BaseSerializer): class Meta: @@ -1529,16 +1531,19 @@ class ResourceSerializer(BaseSerializer): return ret +""" 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) + content_type = ContentType.objects.get(pk=self.context['view'].content_type_id) + object_id = self.context['view'].object_id + obj = content_type.model_class().objects.get(pk=object_id) + if 'summary_fields' not in ret: ret['summary_fields'] = {} - ret['summary_fields']['permissions'] = resource.get_permissions(user) + ret['summary_fields']['permissions'] = get_user_permissions_on_resource(obj, user) def format_role_perm(role): role_dict = { 'id': role.id, 'name': role.name, 'description': role.description} @@ -1549,13 +1554,14 @@ class ResourceAccessListElementSerializer(UserSerializer): except: pass - return { 'role': role_dict, 'permissions': resource.get_role_permissions(role)} + return { 'role': role_dict, 'permissions': get_role_permissions_on_resource(obj, role)} - direct_permissive_role_ids = resource.permissions.values_list('role__id') + content_type = ContentType.objects.get_for_model(obj) + direct_permissive_role_ids = RolePermission.objects.filter(content_type=content_type, object_id=obj.id).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') + all_permissive_role_ids = RolePermission.objects.filter(content_type=content_type, object_id=obj.id).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 diff --git a/awx/api/urls.py b/awx/api/urls.py index d3cde02401..3af0ee74d2 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -163,9 +163,9 @@ role_urls = patterns('awx.api.views', ) 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'^$', 'resource_list'), + #url(r'^(?P[0-9]+)/$', 'resource_detail'), + url(r'^(?P[0-9]+)/(?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'), diff --git a/awx/api/views.py b/awx/api/views.py index a30ea1870b..df4bad17a1 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -131,7 +131,6 @@ class ApiV1RootView(APIView): data['system_jobs'] = reverse('api:system_job_list') data['schedules'] = reverse('api:schedule_list') data['roles'] = reverse('api:role_list') - data['resources'] = reverse('api:resource_list') data['notifiers'] = reverse('api:notifier_list') data['notifications'] = reverse('api:notification_list') data['unified_job_templates'] = reverse('api:unified_job_template_list') @@ -3269,6 +3268,7 @@ class RoleChildrenList(SubListAPIView): role = Role.objects.get(pk=self.kwargs['pk']) return role.children +''' class ResourceDetail(RetrieveAPIView): model = Resource @@ -3290,6 +3290,8 @@ class ResourceList(ListAPIView): def get_queryset(self): return Resource.objects.filter(permissions__role__ancestors__members=self.request.user) +''' + class ResourceAccessList(ListAPIView): model = User @@ -3298,9 +3300,13 @@ class ResourceAccessList(ListAPIView): 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()]) + self.content_type_id = self.kwargs['content_type_id'] + self.object_id = self.kwargs['pk'] + #resource = Resource.objects.get(pk=self.kwargs['pk']) + content_type = ContentType.objects.get(pk=self.content_type_id) + obj = content_type.model_class().objects.get(pk=self.object_id) + + roles = set([p.role for p in obj.role_permissions.all()]) ancestors = set() for r in roles: ancestors.update(set(r.ancestors.all())) diff --git a/awx/main/access.py b/awx/main/access.py index 96f632e832..a67c765e8d 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1722,34 +1722,6 @@ class RoleAccess(BaseAccess): return False -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) @@ -1777,6 +1749,5 @@ register_access(ActivityStream, ActivityStreamAccess) register_access(CustomInventoryScript, CustomInventoryScriptAccess) register_access(TowerSettings, TowerSettingsAccess) register_access(Role, RoleAccess) -register_access(Resource, ResourceAccess) register_access(Notifier, NotifierAccess) register_access(Notification, NotificationAccess) diff --git a/awx/main/fields.py b/awx/main/fields.py index b3efcd20e6..793a12c332 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -1,6 +1,8 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +import traceback + # Django from django.db import connection from django.db.models.signals import ( @@ -23,10 +25,10 @@ from django.db.transaction import TransactionManagementError # AWX -from awx.main.models.rbac import Resource, RolePermission, Role +from awx.main.models.rbac import RolePermission, Role -__all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField'] +__all__ = ['AutoOneToOneField', 'ImplicitRoleField'] # Based on AutoOneToOneField from django-annoying: @@ -59,53 +61,6 @@ class AutoOneToOneField(models.OneToOneField): -class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor): - """Descriptor for access to the object from its related class.""" - - def __init__(self, *args, **kwargs): - super(ResourceFieldDescriptor, self).__init__(*args, **kwargs) - - def __get__(self, instance, instance_type=None): - resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) - if resource: - return resource - if connection.needs_rollback: - raise TransactionManagementError('Current transaction has failed, cannot create implicit resource') - resource = Resource.objects.create(content_object=instance) - setattr(instance, self.field.name, resource) - if instance.pk: - instance.save(update_fields=[self.field.name,]) - return resource - - -class ImplicitResourceField(models.ForeignKey): - """Creates an associated resource object if one doesn't already exist""" - - def __init__(self, *args, **kwargs): - kwargs.setdefault('to', 'Resource') - kwargs.setdefault('related_name', '+') - kwargs.setdefault('null', 'True') - super(ImplicitResourceField, self).__init__(*args, **kwargs) - - def contribute_to_class(self, cls, name): - super(ImplicitResourceField, self).contribute_to_class(cls, name) - setattr(cls, self.name, ResourceFieldDescriptor(self)) - post_save.connect(self._post_save, cls, True) - post_delete.connect(self._post_delete, cls, True) - - def _post_save(self, instance, *args, **kwargs): - # Ensures our resource object exists and that it's content_object - # points back to our hosting instance. - this_resource = getattr(instance, self.name) - if not this_resource.object_id: - this_resource.content_object = instance - this_resource.save() - - def _post_delete(self, instance, *args, **kwargs): - getattr(instance, self.name).delete() - - - def resolve_role_field(obj, field): ret = [] @@ -153,9 +108,13 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): if connection.needs_rollback: raise TransactionManagementError('Current transaction has failed, cannot create implicit role') - role = Role.objects.create(name=self.role_name, description=self.role_description, content_object=instance) - if self.parent_role: + role = Role.objects.create(name=self.role_name, description=self.role_description, content_object=instance) + setattr(instance, self.field.name, role) + if instance.pk: + instance.save(update_fields=[self.field.name,]) + + if self.parent_role: # Add all non-null parent roles as parents paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] for path in paths: @@ -165,14 +124,11 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor): parents = resolve_role_field(instance, path) for parent in parents: role.parents.add(parent) - setattr(instance, self.field.name, role) - if instance.pk: - instance.save(update_fields=[self.field.name,]) if self.permissions is not None: permissions = RolePermission( role=role, - resource=instance.resource + resource=instance ) if 'all' in self.permissions and self.permissions['all']: @@ -289,48 +245,29 @@ class ImplicitRoleField(models.ForeignKey): def _post_init(self, instance, *args, **kwargs): if not self.parent_role: return - #if not hasattr(instance, self.name): - # getattr(instance, self.name) + if not instance.pk: + return + + self._calc_original_parents(instance) + + def _calc_original_parents(self, instance): if not hasattr(self, '__original_parent_roles'): + setattr(self, '__original_parent_roles', []) # do not just self.__original_parent_roles=[], it's not the same here paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] - all_parents = set() + original_parent_roles = set() for path in paths: if path.startswith("singleton:"): parents = [Role.singleton(path[10:])] else: parents = resolve_role_field(instance, path) for parent in parents: - all_parents.add(parent) - #role.parents.add(parent) - self.__original_parent_roles = all_parents + original_parent_roles.add(parent) + setattr(self, '__original_parent_roles', original_parent_roles) - ''' - field_names = self.parent_role - if type(field_names) is not list: - field_names = [field_names] - self.__original_values = {} - for field_name in field_names: - if field_name.startswith('singleton:'): - continue - first_field_name = field_name.split('.')[0] - self.__original_values[first_field_name] = getattr(instance, first_field_name) - ''' - else: - print('WE DO NEED THIS') - pass - - def _post_save(self, instance, *args, **kwargs): + def _post_save(self, instance, created, *args, **kwargs): # Ensure that our field gets initialized after our first save this_role = getattr(instance, self.name) - if not this_role.object_id: - # Ensure our ref back to our instance is set. This will not be set the - # first time the object is saved because we create the role in our _post_init - # but that happens before an id for the instance has been set (because it - # hasn't been saved yet!). Now that everything has an id, we patch things - # so the role references the instance. - this_role.content_object = instance - this_role.save() # As object relations change, the role hierarchy might also change if the relations # that changed were referenced in our magic parent_role field. This code synchronizes @@ -338,8 +275,12 @@ class ImplicitRoleField(models.ForeignKey): if not self.parent_role: return + if created: + self._calc_original_parents(instance) + return + paths = self.parent_role if type(self.parent_role) is list else [self.parent_role] - original_parents = self.__original_parent_roles + original_parents = getattr(self, '__original_parent_roles') new_parents = set() for path in paths: if path.startswith("singleton:"): @@ -356,7 +297,7 @@ class ImplicitRoleField(models.ForeignKey): this_role.parents.add(role) Role.unpause_role_ancestor_rebuilding() - self.__original_parent_roles = new_parents + setattr(self, '__original_parent_roles', new_parents) def _post_delete(self, instance, *args, **kwargs): this_role = getattr(instance, self.name) diff --git a/awx/main/migrations/0006_v300_rbac_changes.py b/awx/main/migrations/0006_v300_rbac_changes.py index e85421573f..54d8442c27 100644 --- a/awx/main/migrations/0006_v300_rbac_changes.py +++ b/awx/main/migrations/0006_v300_rbac_changes.py @@ -18,26 +18,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='Resource', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('description', models.TextField(default=b'', blank=True)), - ('active', models.BooleanField(default=True, editable=False)), - ('name', models.CharField(max_length=512)), - ('object_id', models.PositiveIntegerField(default=None, null=True)), - ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType', null=True)), - ('created_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('modified_by', models.ForeignKey(related_name="{u'class': 'resource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), - ], - options={ - 'db_table': 'main_rbac_resources', - 'verbose_name_plural': 'resources', - }, - ), migrations.CreateModel( name='Role', fields=[ @@ -68,6 +48,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', models.DateTimeField(default=None, editable=False)), ('modified', models.DateTimeField(default=None, editable=False)), + ('object_id', models.PositiveIntegerField(default=None)), ('create', models.IntegerField(default=0)), ('read', models.IntegerField(default=0)), ('write', models.IntegerField(default=0)), @@ -76,7 +57,7 @@ class Migration(migrations.Migration): ('execute', models.IntegerField(default=0)), ('scm_update', models.IntegerField(default=0)), ('use', models.IntegerField(default=0)), - ('resource', models.ForeignKey(related_name='permissions', to='main.Resource')), + ('content_type', models.ForeignKey(default=None, to='contenttypes.ContentType')), ('role', models.ForeignKey(related_name='permissions', to='main.Role')), ], options={ @@ -84,21 +65,32 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'permissions', }, ), - migrations.AlterField( - model_name='towersettings', - name='value', - field=models.TextField(blank=True), + migrations.CreateModel( + name='UserResource', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('active', models.BooleanField(default=True, editable=False)), + ('name', models.CharField(max_length=512)), + ('admin_role', awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True')), + ('created_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ('user', awx.main.fields.AutoOneToOneField(related_name='resource', editable=False, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'main_rbac_user_resource', + 'verbose_name': 'user_resource', + 'verbose_name_plural': 'user_resources', + }, ), migrations.AddField( model_name='credential', name='owner_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='credential', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='credential', name='usage_role', @@ -119,21 +111,11 @@ class Migration(migrations.Migration): name='executor_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='group', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='group', name='updater_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='host', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='inventory', name='admin_role', @@ -149,21 +131,11 @@ class Migration(migrations.Migration): name='executor_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='inventory', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='inventory', name='updater_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='inventorysource', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='jobtemplate', name='admin_role', @@ -179,11 +151,6 @@ class Migration(migrations.Migration): name='executor_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='jobtemplate', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='organization', name='admin_role', @@ -199,11 +166,6 @@ class Migration(migrations.Migration): name='member_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='organization', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='project', name='admin_role', @@ -219,11 +181,6 @@ class Migration(migrations.Migration): name='member_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='project', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), migrations.AddField( model_name='project', name='scm_update_role', @@ -244,37 +201,12 @@ class Migration(migrations.Migration): name='member_role', field=awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True'), ), - migrations.AddField( - model_name='team', - name='resource', - field=awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True'), - ), - - migrations.CreateModel( - name='UserResource', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('description', models.TextField(default=b'', blank=True)), - ('active', models.BooleanField(default=True, editable=False)), - ('name', models.CharField(max_length=512)), - ('admin_role', awx.main.fields.ImplicitRoleField(related_name='+', to='main.Role', null=b'True')), - ('created_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('modified_by', models.ForeignKey(related_name="{u'class': 'userresource', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('resource', awx.main.fields.ImplicitResourceField(related_name='+', to='main.Resource', null=b'True')), - ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), - ('user', awx.main.fields.AutoOneToOneField(related_name='resource', editable=False, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'main_rbac_user_resource', - 'verbose_name': 'user_resource', - 'verbose_name_plural': 'user_resources', - }, - ), migrations.AlterUniqueTogether( name='userresource', unique_together=set([('user', 'admin_role')]), ), - + migrations.AlterIndexTogether( + name='rolepermission', + index_together=set([('content_type', 'object_id')]), + ), ] diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 6d069ed3d4..17aa5a9b5e 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -1,11 +1,13 @@ # Django from django.db import models from django.db.models.aggregates import Max -from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericRelation # AWX -from awx.main.models.rbac import Resource -from awx.main.fields import ImplicitResourceField +from awx.main.models.rbac import ( + get_user_permissions_on_resource, + get_role_permissions_on_resource, +) __all__ = ['ResourceMixin'] @@ -15,7 +17,7 @@ class ResourceMixin(models.Model): class Meta: abstract = True - resource = ImplicitResourceField() + role_permissions = GenericRelation('main.RolePermission') @classmethod def accessible_objects(cls, user, permissions): @@ -31,19 +33,46 @@ class ResourceMixin(models.Model): `myresource.get_permissions(user)`. ''' - qs = Resource.objects.filter( - content_type=ContentType.objects.get_for_model(cls), - permissions__role__ancestors__members=user + qs = cls.objects.filter( + role_permissions__role__ancestors__members=user ) for perm in permissions: - qs = qs.annotate(**{'max_' + perm: Max('permissions__' + perm)}) + qs = qs.annotate(**{'max_' + perm: Max('role_permissions__' + perm)}) qs = qs.filter(**{'max_' + perm: int(permissions[perm])}) - return cls.objects.filter(resource__in=qs) + #return cls.objects.filter(resource__in=qs) + return qs def get_permissions(self, user): - return self.resource.get_permissions(user) + ''' + Returns a dict (or None) of the permissions a user has for a given + resource. + + Note: Each field in the dict is the `or` of all respective permissions + that have been granted to the roles that are applicable for the given + user. + + In example, if a user has been granted read access through a permission + on one role and write access through a permission on a separate role, + the returned dict will denote that the user has both read and write + access. + ''' + + return get_user_permissions_on_resource(self, user) + + + 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. + ''' + + return get_role_permissions_on_resource(self, role) + def accessible_by(self, user, permissions): ''' diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 0b2fb64290..caec516d23 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -19,7 +19,8 @@ from awx.main.models.base import * # noqa __all__ = [ 'Role', 'RolePermission', - 'Resource', + 'get_user_permissions_on_resource', + 'get_role_permissions_on_resource', 'ROLE_SINGLETON_SYSTEM_ADMINISTRATOR', 'ROLE_SINGLETON_SYSTEM_AUDITOR', ] @@ -120,26 +121,6 @@ class Role(CommonModelNameNotUnique): for child in self.children.all(): child.rebuild_role_ancestor_list() - def grant(self, resource, permissions): - # take either the raw Resource or something that includes the ResourceMixin - resource = resource if type(resource) is Resource else resource.resource - - if 'all' in permissions and permissions['all']: - del permissions['all'] - permissions['create'] = True - permissions['read'] = True - permissions['write'] = True - permissions['update'] = True - permissions['delete'] = True - permissions['scm_update'] = True - permissions['use'] = True - permissions['execute'] = True - - permission = RolePermission(role=self, resource=resource) - for k in permissions: - setattr(permission, k, int(permissions[k])) - permission.save() - @staticmethod def visible_roles(user): return Role.objects.filter(Q(descendents__in=user.roles.filter()) | Q(ancestors__in=user.roles.filter())) @@ -149,14 +130,14 @@ class Role(CommonModelNameNotUnique): try: return Role.objects.get(singleton_name=name) except Role.DoesNotExist: - ret = Role(singleton_name=name, name=name) - ret.save() + ret = Role.objects.create(singleton_name=name, name=name) return ret def is_ancestor_of(self, role): return role.ancestors.filter(id=self.id).exists() +""" class Resource(CommonModelNameNotUnique): ''' Role model @@ -171,69 +152,7 @@ class Resource(CommonModelNameNotUnique): object_id = models.PositiveIntegerField(null=True, default=None) content_object = GenericForeignKey('content_type', 'object_id') - def get_permissions(self, user): - ''' - Returns a dict (or None) of the permissions a user has for a given - resource. - - Note: Each field in the dict is the `or` of all respective permissions - that have been granted to the roles that are applicable for the given - user. - - In example, if a user has been granted read access through a permission - on one role and write access through a permission on a separate role, - the returned dict will denote that the user has both read and write - access. - ''' - - qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self) - - qs = qs.annotate(max_create = Max('roles__descendents__permissions__create')) - qs = qs.annotate(max_read = Max('roles__descendents__permissions__read')) - qs = qs.annotate(max_write = Max('roles__descendents__permissions__write')) - qs = qs.annotate(max_update = Max('roles__descendents__permissions__update')) - qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete')) - qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update')) - qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute')) - qs = qs.annotate(max_use = Max('roles__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 - - 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): @@ -245,6 +164,9 @@ class RolePermission(CreatedModifiedModel): app_label = 'main' verbose_name_plural = _('permissions') db_table = 'main_rbac_permissions' + index_together = [ + ('content_type', 'object_id') + ] role = models.ForeignKey( Role, @@ -252,12 +174,10 @@ class RolePermission(CreatedModifiedModel): on_delete=models.CASCADE, related_name='permissions', ) - resource = models.ForeignKey( - Resource, - null=False, - on_delete=models.CASCADE, - related_name='permissions', - ) + content_type = models.ForeignKey(ContentType, null=False, default=None) + object_id = models.PositiveIntegerField(null=False, default=None) + resource = GenericForeignKey('content_type', 'object_id') + create = models.IntegerField(default = 0) read = models.IntegerField(default = 0) write = models.IntegerField(default = 0) @@ -266,3 +186,69 @@ class RolePermission(CreatedModifiedModel): execute = models.IntegerField(default = 0) scm_update = models.IntegerField(default = 0) use = models.IntegerField(default = 0) + + + +def get_user_permissions_on_resource(resource, user): + ''' + Returns a dict (or None) of the permissions a user has for a given + resource. + + Note: Each field in the dict is the `or` of all respective permissions + that have been granted to the roles that are applicable for the given + user. + + In example, if a user has been granted read access through a permission + on one role and write access through a permission on a separate role, + the returned dict will denote that the user has both read and write + access. + ''' + + qs = RolePermission.objects.filter( + content_type=ContentType.objects.get_for_model(resource), + object_id=resource.id, + role__ancestors__in=user.roles.all() + ) + + res = qs = qs.aggregate( + create = Max('create'), + read = Max('read'), + write = Max('write'), + update = Max('update'), + delete = Max('delete'), + scm_update = Max('scm_update'), + execute = Max('execute'), + use = Max('use') + ) + if res['read'] is None: + return None + return res + +def get_role_permissions_on_resource(resource, 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 = RolePermission.objects.filter( + content_type=ContentType.objects.get_for_model(resource), + object_id=resource.id, + role__ancestors=role + ) + + res = qs = qs.aggregate( + create = Max('create'), + read = Max('read'), + write = Max('write'), + update = Max('update'), + delete = Max('delete'), + scm_update = Max('scm_update'), + execute = Max('execute'), + use = Max('use') + ) + if res['read'] is None: + return None + return res diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index c99c49aad3..e2ddc34d93 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -1,6 +1,7 @@ import mock # noqa import pytest +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from awx.main.models.rbac import Role, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR @@ -53,8 +54,6 @@ def test_get_roles_list_user(organization, inventory, team, get, user): assert team.member_role.id not in role_hash - - @pytest.mark.django_db def test_cant_create_role(post, admin): "Ensure we can't create new roles through the api" @@ -225,7 +224,7 @@ def test_get_role(get, admin, role): assert response.data['id'] == role.id @pytest.mark.django_db -def test_put_role(put, admin, role): +def test_put_role_405(put, admin, role): url = reverse('api:role_detail', args=(role.id,)) response = put(url, {'name': 'Some new name'}, admin) assert response.status_code == 405 @@ -233,7 +232,7 @@ def test_put_role(put, admin, role): #assert r.name == 'Some new name' @pytest.mark.django_db -def test_put_role_access_denied(put, alice, admin, role): +def test_put_role_access_denied(put, alice, role): url = reverse('api:role_detail', args=(role.id,)) response = put(url, {'name': 'Some new name'}, alice) assert response.status_code == 403 or response.status_code == 405 @@ -400,8 +399,10 @@ def test_role_children(get, team, admin, role): @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,)) + content_type_id = ContentType.objects.get_for_model(team).pk + url = reverse('api:resource_access_list', args=(content_type_id, team.id,)) res = get(url, admin) + print(res.data) assert res.status_code == 200 @@ -420,7 +421,6 @@ def test_ensure_rbac_fields_are_present(organization, get, admin): 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) @@ -434,7 +434,6 @@ def test_ensure_rbac_fields_are_present(organization, get, admin): @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 @@ -446,7 +445,6 @@ def test_ensure_permissions_is_present(organization, get, user): @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 diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py index deae21b3b8..941a7c6042 100644 --- a/awx/main/tests/functional/test_rbac_core.py +++ b/awx/main/tests/functional/test_rbac_core.py @@ -2,7 +2,7 @@ import pytest from awx.main.models import ( Role, - Resource, + RolePermission, Organization, ) @@ -13,17 +13,27 @@ def test_auto_inheritance_by_children(organization, alice): B = Role.objects.create(name='B') A.members.add(alice) + + assert organization.accessible_by(alice, {'read': True}) is False + assert Organization.accessible_objects(alice, {'read': True}).count() == 0 A.children.add(B) assert organization.accessible_by(alice, {'read': True}) is False + assert Organization.accessible_objects(alice, {'read': True}).count() == 0 A.children.add(organization.admin_role) assert organization.accessible_by(alice, {'read': True}) is True + assert Organization.accessible_objects(alice, {'read': True}).count() == 1 A.children.remove(organization.admin_role) assert organization.accessible_by(alice, {'read': True}) is False B.children.add(organization.admin_role) assert organization.accessible_by(alice, {'read': True}) is True B.children.remove(organization.admin_role) assert organization.accessible_by(alice, {'read': True}) is False + assert Organization.accessible_objects(alice, {'read': True}).count() == 0 + + # We've had the case where our pre/post save init handlers in our field descriptors + # end up creating a ton of role objects because of various not-so-obvious issues + assert Role.objects.count() < 50 @pytest.mark.django_db @@ -53,12 +63,29 @@ def test_permission_union(organization, alice): B.members.add(alice) assert organization.accessible_by(alice, {'read': True, 'write': True}) is False - A.grant(organization, {'read': True}) + RolePermission.objects.create(role=A, resource=organization, read=True) assert organization.accessible_by(alice, {'read': True, 'write': True}) is False - B.grant(organization, {'write': True}) + RolePermission.objects.create(role=A, resource=organization, write=True) assert organization.accessible_by(alice, {'read': True, 'write': True}) is True +@pytest.mark.django_db +def test_accessible_objects(organization, alice, bob): + A = Role.objects.create(name='A') + A.members.add(alice) + B = Role.objects.create(name='B') + B.members.add(alice) + B.members.add(bob) + + assert Organization.accessible_objects(alice, {'read': True, 'write': True}).count() == 0 + RolePermission.objects.create(role=A, resource=organization, read=True) + assert Organization.accessible_objects(alice, {'read': True, 'write': True}).count() == 0 + assert Organization.accessible_objects(bob, {'read': True, 'write': True}).count() == 0 + RolePermission.objects.create(role=B, resource=organization, write=True) + assert Organization.accessible_objects(alice, {'read': True, 'write': True}).count() == 1 + assert Organization.accessible_objects(bob, {'read': True, 'write': True}).count() == 0 + assert Organization.accessible_objects(bob, {'read': True, 'write': True}).count() == 0 + @pytest.mark.django_db def test_team_symantics(organization, team, alice): assert organization.accessible_by(alice, {'read': True}) is False @@ -110,32 +137,28 @@ def test_implicit_deletes(alice): delorg = Organization.objects.create(name='test-org') delorg.admin_role.members.add(alice) - resource_id = delorg.resource.id admin_role_id = delorg.admin_role.id auditor_role_id = delorg.auditor_role.id assert Role.objects.filter(id=admin_role_id).count() == 1 assert Role.objects.filter(id=auditor_role_id).count() == 1 - assert Resource.objects.filter(id=resource_id).count() == 1 n_alice_roles = alice.roles.count() n_system_admin_children = Role.singleton('System Administrator').children.count() + rp = RolePermission.objects.create(role=delorg.admin_role, resource=delorg, read=True) delorg.delete() assert Role.objects.filter(id=admin_role_id).count() == 0 assert Role.objects.filter(id=auditor_role_id).count() == 0 - assert Resource.objects.filter(id=resource_id).count() == 0 assert alice.roles.count() == (n_alice_roles - 1) + assert RolePermission.objects.filter(id=rp.id).count() == 0 assert Role.singleton('System Administrator').children.count() == (n_system_admin_children - 1) @pytest.mark.django_db def test_content_object(user): - 'Ensure our conent_object stuf seems to be working' + 'Ensure our content_object stuf seems to be working' - print('Creating organization') org = Organization.objects.create(name='test-org') - print('Organizaiton id: %d resource: %d admin_role: %d' % (org.id, org.resource.id, org.admin_role.id)) - assert org.resource.content_object.id == org.id assert org.admin_role.content_object.id == org.id @pytest.mark.django_db From 87219135affcfd51c4c44d5a0a0ad650d2a286fe Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 10:39:31 -0500 Subject: [PATCH 113/297] Removed unneeded import --- awx/main/fields.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 793a12c332..ee02c27440 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -1,8 +1,6 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -import traceback - # Django from django.db import connection from django.db.models.signals import ( From efcd4efda2a02ae9d7eb06fba36b23133d7df47d Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 11:31:00 -0500 Subject: [PATCH 114/297] Moved the rbac field removal migration to happen after the migrate script part of the rbac migration --- ...{0005_rbac_remove_users.py => 0008_v300_rbac_drop_fields.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0005_rbac_remove_users.py => 0008_v300_rbac_drop_fields.py} (91%) diff --git a/awx/main/migrations/0005_rbac_remove_users.py b/awx/main/migrations/0008_v300_rbac_drop_fields.py similarity index 91% rename from awx/main/migrations/0005_rbac_remove_users.py rename to awx/main/migrations/0008_v300_rbac_drop_fields.py index 535e78a73a..250558c42c 100644 --- a/awx/main/migrations/0005_rbac_remove_users.py +++ b/awx/main/migrations/0008_v300_rbac_drop_fields.py @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0004_rbac_migrations'), + ('main', '0007_v300_rbac_migrations'), ] operations = [ From 1989012fd5eed9672129f4b34970ff789cc50778 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 11:41:42 -0500 Subject: [PATCH 115/297] Moved access_list url to /id/access_list Eg: organizations/1/access_list will now return a list of all users who have access to that organization. This replaces our initial implementation which was resources/id/access_list --- awx/api/generics.py | 19 +++++++++ awx/api/serializers.py | 49 ++++++----------------- awx/api/urls.py | 18 ++++----- awx/api/views.py | 90 ++++++++++++++++++++++-------------------- 4 files changed, 85 insertions(+), 91 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index 93dd3ba444..a660b2acba 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -26,6 +26,7 @@ from rest_framework import views # AWX from awx.main.models import * # noqa from awx.main.utils import * # noqa +from awx.api.serializers import ResourceAccessListElementSerializer __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'ListCreateAPIView', 'SubListAPIView', 'SubListCreateAPIView', @@ -33,6 +34,7 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'RetrieveUpdateAPIView', 'RetrieveDestroyAPIView', 'RetrieveUpdateDestroyAPIView', 'DestroyAPIView', 'SubDetailAPIView', + 'ResourceAccessList', 'ParentMixin',] logger = logging.getLogger('awx.api.generics') @@ -473,3 +475,20 @@ class RetrieveUpdateDestroyAPIView(RetrieveUpdateAPIView, RetrieveDestroyAPIView class DestroyAPIView(GenericAPIView, generics.DestroyAPIView): pass + + +class ResourceAccessList(ListAPIView): + + serializer_class = ResourceAccessListElementSerializer + + def get_queryset(self): + self.object_id = self.kwargs['pk'] + resource_model = getattr(self, 'resource_model') + obj = resource_model.objects.get(pk=self.object_id) + + roles = set([p.role for p in obj.role_permissions.all()]) + ancestors = set() + for r in roles: + ancestors.update(set(r.ancestors.all())) + return User.objects.filter(roles__in=list(ancestors)) + diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 80defe4d8f..f58a4af1c8 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -238,7 +238,7 @@ class BaseSerializer(serializers.ModelSerializer): __metaclass__ = BaseSerializerMetaclass class Meta: - fields = ('id', 'type', 'resource_id', 'url', 'related', 'summary_fields', 'created', + fields = ('id', 'type', '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. @@ -253,7 +253,6 @@ class BaseSerializer(serializers.ModelSerializer): created = serializers.SerializerMethodField() modified = serializers.SerializerMethodField() active = serializers.SerializerMethodField() - resource_id = serializers.SerializerMethodField() def get_type(self, obj): @@ -293,9 +292,6 @@ 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): - content_type_id = ContentType.objects.get_for_model(obj).pk - res['resource_access_list'] = reverse('api:resource_access_list', kwargs={'content_type_id': content_type_id, 'pk': obj.pk}) return res def _get_summary_fields(self, obj): @@ -366,11 +362,6 @@ class BaseSerializer(serializers.ModelSerializer): summary_fields['roles'] = roles return summary_fields - def get_resource_id(self, obj): - content_type_id = ContentType.objects.get_for_model(obj).pk - return '%d/%d' % (content_type_id, obj.pk) - return None - def get_created(self, obj): if obj is None: return None @@ -545,8 +536,6 @@ class BaseSerializer(serializers.ModelSerializer): def to_representation(self, obj): ret = super(BaseSerializer, self).to_representation(obj) - if 'resource_id' in ret and ret['resource_id'] is None: - ret.pop('resource_id') return ret @@ -817,6 +806,7 @@ class UserSerializer(BaseSerializer): credentials = reverse('api:user_credentials_list', args=(obj.pk,)), roles = reverse('api:user_roles_list', args=(obj.pk,)), activity_stream = reverse('api:user_activity_stream_list', args=(obj.pk,)), + access_list = reverse('api:user_access_list', args=(obj.pk,)), )) return res @@ -871,6 +861,7 @@ class OrganizationSerializer(BaseSerializer): notifiers_any = reverse('api:organization_notifiers_any_list', args=(obj.pk,)), notifiers_success = reverse('api:organization_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:organization_notifiers_error_list', args=(obj.pk,)), + access_list = reverse('api:organization_access_list', args=(obj.pk,)), )) return res @@ -943,6 +934,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): notifiers_any = reverse('api:project_notifiers_any_list', args=(obj.pk,)), notifiers_success = reverse('api:project_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:project_notifiers_error_list', args=(obj.pk,)), + access_list = reverse('api:project_access_list', args=(obj.pk,)), )) # Backwards compatibility. if obj.current_update: @@ -1042,6 +1034,7 @@ class InventorySerializer(BaseSerializerWithVariables): job_templates = reverse('api:inventory_job_template_list', args=(obj.pk,)), scan_job_templates = reverse('api:inventory_scan_job_template_list', args=(obj.pk,)), ad_hoc_commands = reverse('api:inventory_ad_hoc_commands_list', args=(obj.pk,)), + access_list = reverse('api:inventory_access_list', args=(obj.pk,)), #single_fact = reverse('api:inventory_single_fact_view', args=(obj.pk,)), )) if obj.organization and obj.organization.active: @@ -1212,6 +1205,7 @@ class GroupSerializer(BaseSerializerWithVariables): activity_stream = reverse('api:group_activity_stream_list', args=(obj.pk,)), inventory_sources = reverse('api:group_inventory_sources_list', args=(obj.pk,)), ad_hoc_commands = reverse('api:group_ad_hoc_commands_list', args=(obj.pk,)), + access_list = reverse('api:group_access_list', args=(obj.pk,)), #single_fact = reverse('api:group_single_fact_view', args=(obj.pk,)), )) if obj.inventory and obj.inventory.active: @@ -1475,6 +1469,7 @@ class TeamSerializer(BaseSerializer): credentials = reverse('api:team_credentials_list', args=(obj.pk,)), roles = reverse('api:team_roles_list', args=(obj.pk,)), activity_stream = reverse('api:team_activity_stream_list', args=(obj.pk,)), + access_list = reverse('api:team_access_list', args=(obj.pk,)), )) if obj.organization and obj.organization.active: res['organization'] = reverse('api:organization_detail', args=(obj.organization.pk,)) @@ -1509,37 +1504,13 @@ class RoleSerializer(BaseSerializer): return ret -""" -class ResourceSerializer(BaseSerializer): - - class Meta: - model = Resource - fields = ('*',) - - def get_related(self, obj): - ret = super(ResourceSerializer, self).get_related(obj) - ret['access_list'] = reverse('api:resource_access_list', args=(obj.pk,)) - try: - if obj.content_object: - ret.update(reverseGenericForeignKey(obj.content_object)) - except AttributeError as e: - print(e) - # AttributeError's happen if our content_object is pointing at - # a model that no longer exists. This is dirty data and ideally - # doesn't exist, but in case it does, let's not puke. - pass - - return ret - -""" class ResourceAccessListElementSerializer(UserSerializer): def to_representation(self, user): ret = super(ResourceAccessListElementSerializer, self).to_representation(user) - content_type = ContentType.objects.get(pk=self.context['view'].content_type_id) object_id = self.context['view'].object_id - obj = content_type.model_class().objects.get(pk=object_id) + obj = self.context['view'].resource_model.objects.get(pk=object_id) if 'summary_fields' not in ret: ret['summary_fields'] = {} @@ -1611,7 +1582,8 @@ class CredentialSerializer(BaseSerializer): def get_related(self, obj): res = super(CredentialSerializer, self).get_related(obj) res.update(dict( - activity_stream = reverse('api:credential_activity_stream_list', args=(obj.pk,)) + activity_stream = reverse('api:credential_activity_stream_list', args=(obj.pk,)), + access_list = reverse('api:credential_access_list', args=(obj.pk,)), )) if obj.user: res['user'] = reverse('api:user_detail', args=(obj.user.pk,)) @@ -1690,6 +1662,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notifiers_any = reverse('api:job_template_notifiers_any_list', args=(obj.pk,)), notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)), + access_list = reverse('api:job_template_access_list', args=(obj.pk,)), )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) diff --git a/awx/api/urls.py b/awx/api/urls.py index 3af0ee74d2..8b07352343 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -24,6 +24,7 @@ organization_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/notifiers_any/$', 'organization_notifiers_any_list'), url(r'^(?P[0-9]+)/notifiers_error/$', 'organization_notifiers_error_list'), url(r'^(?P[0-9]+)/notifiers_success/$', 'organization_notifiers_success_list'), + url(r'^(?P[0-9]+)/access_list/$', 'organization_access_list'), ) user_urls = patterns('awx.api.views', @@ -36,6 +37,7 @@ user_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/credentials/$', 'user_credentials_list'), url(r'^(?P[0-9]+)/roles/$', 'user_roles_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'user_activity_stream_list'), + url(r'^(?P[0-9]+)/access_list/$', 'user_access_list'), ) project_urls = patterns('awx.api.views', @@ -51,6 +53,7 @@ project_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/notifiers_any/$', 'project_notifiers_any_list'), url(r'^(?P[0-9]+)/notifiers_error/$', 'project_notifiers_error_list'), url(r'^(?P[0-9]+)/notifiers_success/$', 'project_notifiers_success_list'), + url(r'^(?P[0-9]+)/access_list/$', 'project_access_list'), ) project_update_urls = patterns('awx.api.views', @@ -68,6 +71,7 @@ team_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/credentials/$', 'team_credentials_list'), url(r'^(?P[0-9]+)/roles/$', 'team_roles_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'team_activity_stream_list'), + url(r'^(?P[0-9]+)/access_list/$', 'team_access_list'), ) inventory_urls = patterns('awx.api.views', @@ -84,6 +88,7 @@ inventory_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/job_templates/$', 'inventory_job_template_list'), url(r'^(?P[0-9]+)/scan_job_templates/$', 'inventory_scan_job_template_list'), url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'inventory_ad_hoc_commands_list'), + url(r'^(?P[0-9]+)/access_list/$', 'inventory_access_list'), #url(r'^(?P[0-9]+)/single_fact/$', 'inventory_single_fact_view'), ) @@ -117,6 +122,7 @@ group_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/activity_stream/$', 'group_activity_stream_list'), url(r'^(?P[0-9]+)/inventory_sources/$', 'group_inventory_sources_list'), url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'group_ad_hoc_commands_list'), + url(r'^(?P[0-9]+)/access_list/$', 'group_access_list'), #url(r'^(?P[0-9]+)/single_fact/$', 'group_single_fact_view'), ) @@ -150,6 +156,7 @@ credential_urls = patterns('awx.api.views', url(r'^$', 'credential_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'credential_activity_stream_list'), url(r'^(?P[0-9]+)/$', 'credential_detail'), + url(r'^(?P[0-9]+)/access_list/$', 'credential_access_list'), # See also credentials resources on users/teams. ) @@ -162,15 +169,6 @@ role_urls = patterns('awx.api.views', 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]+)/(?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', url(r'^$', 'job_template_list'), url(r'^(?P[0-9]+)/$', 'job_template_detail'), @@ -183,6 +181,7 @@ job_template_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/notifiers_any/$', 'job_template_notifiers_any_list'), url(r'^(?P[0-9]+)/notifiers_error/$', 'job_template_notifiers_error_list'), url(r'^(?P[0-9]+)/notifiers_success/$', 'job_template_notifiers_success_list'), + url(r'^(?P[0-9]+)/access_list/$', 'job_template_access_list'), ) job_urls = patterns('awx.api.views', @@ -292,7 +291,6 @@ v1_urls = patterns('awx.api.views', url(r'^inventory_scripts/', include(inventory_script_urls)), url(r'^credentials/', include(credential_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 df4bad17a1..959b4dd2cf 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -703,6 +703,12 @@ class OrganizationNotifiersSuccessList(SubListCreateAttachDetachAPIView): parent_model = Organization relationship = 'notifiers_success' +class OrganizationAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Organization + new_in_300 = True + class TeamList(ListCreateAPIView): model = Team @@ -783,6 +789,11 @@ class TeamActivityStreamList(SubListAPIView): Q(credential__in=parent.credentials.all()) | Q(permission__in=parent.permissions.all())) +class TeamAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Team + new_in_300 = True class ProjectList(ListCreateAPIView): @@ -947,6 +958,12 @@ class ProjectUpdateNotificationsList(SubListAPIView): parent_model = Project relationship = 'notifications' +class ProjectAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Project + new_in_300 = True + class UserList(ListCreateAPIView): model = User @@ -1086,6 +1103,12 @@ class UserDetail(RetrieveUpdateDestroyAPIView): own_credential.mark_inactive() return super(UserDetail, self).destroy(request, *args, **kwargs) +class UserAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = User + new_in_300 = True + class CredentialList(ListCreateAPIView): model = Credential @@ -1114,6 +1137,11 @@ class CredentialActivityStreamList(SubListAPIView): # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) +class CredentialAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Credential + new_in_300 = True class InventoryScriptList(ListCreateAPIView): @@ -1174,6 +1202,12 @@ class InventoryActivityStreamList(SubListAPIView): qs = self.request.user.get_queryset(self.model) return qs.filter(Q(inventory=parent) | Q(host__in=parent.hosts.all()) | Q(group__in=parent.groups.all())) +class InventoryAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Inventory + new_in_300 = True + class InventoryJobTemplateList(SubListAPIView): model = JobTemplate @@ -1509,6 +1543,13 @@ class GroupDetail(RetrieveUpdateDestroyAPIView): obj.mark_inactive_recursive() return Response(status=status.HTTP_204_NO_CONTENT) +class GroupAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = Group + new_in_300 = True + + class InventoryGroupsList(SubListCreateAttachDetachAPIView): model = Group @@ -2186,6 +2227,12 @@ class JobTemplateJobsList(SubListCreateAPIView): relationship = 'jobs' parent_key = 'job_template' +class JobTemplateAccessList(ResourceAccessList): + + model = User # needs to be User for AccessLists's + resource_model = JobTemplate + new_in_300 = True + class SystemJobTemplateList(ListAPIView): model = SystemJobTemplate @@ -3268,49 +3315,6 @@ class RoleChildrenList(SubListAPIView): role = Role.objects.get(pk=self.kwargs['pk']) return role.children -''' -class ResourceDetail(RetrieveAPIView): - - model = Resource - serializer_class = ResourceSerializer - permission_classes = (IsAuthenticated,) - 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 ResourceList(ListAPIView): - - model = Resource - serializer_class = ResourceSerializer - permission_classes = (IsAuthenticated,) - new_in_300 = True - - def get_queryset(self): - return Resource.objects.filter(permissions__role__ancestors__members=self.request.user) - -''' - -class ResourceAccessList(ListAPIView): - - model = User - serializer_class = ResourceAccessListElementSerializer - permission_classes = (IsAuthenticated,) - new_in_300 = True - - def get_queryset(self): - self.content_type_id = self.kwargs['content_type_id'] - self.object_id = self.kwargs['pk'] - #resource = Resource.objects.get(pk=self.kwargs['pk']) - content_type = ContentType.objects.get(pk=self.content_type_id) - obj = content_type.model_class().objects.get(pk=self.object_id) - - roles = set([p.role for p in obj.role_permissions.all()]) - ancestors = set() - for r in roles: - ancestors.update(set(r.ancestors.all())) - return User.objects.filter(roles__in=list(ancestors)) From 9c78a85a70c0f2a52454ca8964efa8c152154294 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 12:03:20 -0500 Subject: [PATCH 116/297] Removed old test assertion --- awx/main/tests/functional/test_rbac_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index e2ddc34d93..ce04260fd7 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -419,8 +419,6 @@ def test_ensure_rbac_fields_are_present(organization, get, admin): org = response.data assert 'summary_fields' in org - assert 'resource_id' in org - assert org['resource_id'] > 0 assert 'roles' in org['summary_fields'] org_role_response = get(org['summary_fields']['roles']['admin_role']['url'], admin) From 97a6f23380662473f06ae2fe1f742644186e3e8e Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 9 Mar 2016 13:08:02 -0500 Subject: [PATCH 117/297] Fixed up migrations after last merge --- ...tive_field_changes.py => 0006_v300_active_flag_removal.py} | 2 +- .../{0006_v300_rbac_changes.py => 0007_v300_rbac_changes.py} | 2 +- ...7_v300_rbac_migrations.py => 0008_v300_rbac_migrations.py} | 2 +- ...v300_rbac_drop_fields.py => 0009_v300_rbac_drop_fields.py} | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename awx/main/migrations/{0005_v300_active_field_changes.py => 0006_v300_active_flag_removal.py} (88%) rename awx/main/migrations/{0006_v300_rbac_changes.py => 0007_v300_rbac_changes.py} (99%) rename awx/main/migrations/{0007_v300_rbac_migrations.py => 0008_v300_rbac_migrations.py} (92%) rename awx/main/migrations/{0008_v300_rbac_drop_fields.py => 0009_v300_rbac_drop_fields.py} (84%) diff --git a/awx/main/migrations/0005_v300_active_field_changes.py b/awx/main/migrations/0006_v300_active_flag_removal.py similarity index 88% rename from awx/main/migrations/0005_v300_active_field_changes.py rename to awx/main/migrations/0006_v300_active_flag_removal.py index d7582fc5fb..795db642b2 100644 --- a/awx/main/migrations/0005_v300_active_field_changes.py +++ b/awx/main/migrations/0006_v300_active_flag_removal.py @@ -8,7 +8,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('main', '0004_v300_changes'), + ('main', '0005_v300_changes'), ] operations = [ diff --git a/awx/main/migrations/0006_v300_rbac_changes.py b/awx/main/migrations/0007_v300_rbac_changes.py similarity index 99% rename from awx/main/migrations/0006_v300_rbac_changes.py rename to awx/main/migrations/0007_v300_rbac_changes.py index 54d8442c27..86cc50dc99 100644 --- a/awx/main/migrations/0006_v300_rbac_changes.py +++ b/awx/main/migrations/0007_v300_rbac_changes.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): ('taggit', '0002_auto_20150616_2121'), ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('main', '0005_v300_active_field_changes'), + ('main', '0006_v300_active_flag_removal'), ] operations = [ diff --git a/awx/main/migrations/0007_v300_rbac_migrations.py b/awx/main/migrations/0008_v300_rbac_migrations.py similarity index 92% rename from awx/main/migrations/0007_v300_rbac_migrations.py rename to awx/main/migrations/0008_v300_rbac_migrations.py index d50069ab48..7226c31e1b 100644 --- a/awx/main/migrations/0007_v300_rbac_migrations.py +++ b/awx/main/migrations/0008_v300_rbac_migrations.py @@ -8,7 +8,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('main', '0006_v300_rbac_changes'), + ('main', '0007_v300_rbac_changes'), ] operations = [ diff --git a/awx/main/migrations/0008_v300_rbac_drop_fields.py b/awx/main/migrations/0009_v300_rbac_drop_fields.py similarity index 84% rename from awx/main/migrations/0008_v300_rbac_drop_fields.py rename to awx/main/migrations/0009_v300_rbac_drop_fields.py index 250558c42c..48cb504d67 100644 --- a/awx/main/migrations/0008_v300_rbac_drop_fields.py +++ b/awx/main/migrations/0009_v300_rbac_drop_fields.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('main', '0007_v300_rbac_migrations'), + ('main', '0008_v300_rbac_migrations'), ] operations = [ From 09d46f9336bb6663f5fd9084ce5e4efc12d30fb3 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Wed, 9 Mar 2016 15:33:12 -0500 Subject: [PATCH 118/297] test fixups and re-add can_access --- awx/main/access.py | 22 +++++++++++++++++++-- awx/main/models/__init__.py | 2 ++ awx/main/models/organization.py | 15 -------------- awx/main/tests/functional/test_rbac_api.py | 18 +++++++---------- awx/main/tests/functional/test_rbac_core.py | 9 ++------- 5 files changed, 31 insertions(+), 35 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index b500d526f3..201e8d59f3 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -20,7 +20,7 @@ from awx.main.models.rbac import ALL_PERMISSIONS from awx.api.license import LicenseForbids from awx.main.task_engine import TaskSerializer -__all__ = ['get_user_queryset'] +__all__ = ['get_user_queryset', 'check_user_access'] PERMISSION_TYPES = [ PERM_INVENTORY_ADMIN, @@ -90,6 +90,24 @@ def get_user_queryset(user, model_class): 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): ''' @@ -137,7 +155,7 @@ class BaseAccess(object): return self.can_change(obj, None) else: return bool(self.can_change(obj, None) and - sub_obj.accessible_by(self.user, {'read':True})) + self.user.can_access(type(sub_obj), 'read', sub_obj)) def can_unattach(self, obj, sub_obj, relationship): return self.can_change(obj, None) diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 41131f481e..749807ac74 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -38,7 +38,9 @@ _PythonSerializer.handle_m2m_field = _new_handle_m2m_field # Add custom methods to User model for permissions checks. from django.contrib.auth.models import User # noqa from awx.main.access import * # noqa + User.add_to_class('get_queryset', get_user_queryset) +User.add_to_class('can_access', check_user_access) # Import signal handlers only after models have been defined. import awx.main.signals # noqa diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index d1e7ae3c86..3f86d5032a 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -38,16 +38,6 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin): app_label = 'main' ordering = ('name',) - users = models.ManyToManyField( - 'auth.User', - blank=True, - related_name='organizations', - ) - admins = models.ManyToManyField( - 'auth.User', - blank=True, - related_name='admin_of_organizations', - ) projects = models.ManyToManyField( 'Project', blank=True, @@ -96,11 +86,6 @@ class Team(CommonModelNameNotUnique, ResourceMixin): unique_together = [('organization', 'name')] ordering = ('organization__name', 'name') - users = models.ManyToManyField( - 'auth.User', - blank=True, - related_name='teams', - ) organization = models.ForeignKey( 'Organization', blank=False, diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index ce04260fd7..6e015b48ef 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -91,10 +91,10 @@ def test_get_user_roles_list(get, admin): def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob): 'Users can see roles for other users, but only the roles that that user has access to see as well' organization.member_role.members.add(alice) - organization.admins.add(bob) + organization.admin_role.members.add(bob) custom_role = Role.objects.create(name='custom_role-test_user_view_admin_roles_list') organization.member_role.children.add(custom_role) - team.users.add(bob) + team.member_role.members.add(bob) # alice and bob are in the same org and can see some child role of that org. # Bob is an org admin, alice can see this. @@ -118,7 +118,7 @@ def test_user_view_other_user_roles(organization, inventory, team, get, alice, b assert team.member_role.id not in role_hash # alice can't see this # again but this time alice is part of the team, and should be able to see the team role - team.users.add(alice) + team.member_role.members.add(alice) response = get(url, alice) assert response.status_code == 200 roles = response.data @@ -271,7 +271,7 @@ def test_org_admin_add_user_to_job_template(post, organization, check_jobtemplat 'Tests that a user with permissions to assign/revoke membership to a particular role can do so' org_admin = user('org-admin') joe = user('joe') - organization.admins.add(org_admin) + organization.admin_role.members.add(org_admin) assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False @@ -286,7 +286,7 @@ def test_org_admin_remove_user_to_job_template(post, organization, check_jobtemp 'Tests that a user with permissions to assign/revoke membership to a particular role can do so' org_admin = user('org-admin') joe = user('joe') - organization.admins.add(org_admin) + organization.admin_role.members.add(org_admin) check_jobtemplate.executor_role.members.add(joe) assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True @@ -336,7 +336,6 @@ 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 @@ -347,7 +346,6 @@ 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 @@ -357,7 +355,6 @@ def test_remove_team_from_role(post, team, admin, role): 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 @@ -398,11 +395,10 @@ def test_role_children(get, team, admin, role): @pytest.mark.django_db def test_resource_access_list(get, team, admin, role): - team.users.add(admin) + team.member_role.members.add(admin) content_type_id = ContentType.objects.get_for_model(team).pk - url = reverse('api:resource_access_list', args=(content_type_id, team.id,)) + url = reverse('api:team_access_list', args=(team.id,)) res = get(url, admin) - print(res.data) assert res.status_code == 200 diff --git a/awx/main/tests/functional/test_rbac_core.py b/awx/main/tests/functional/test_rbac_core.py index 941a7c6042..9b3e29f6e8 100644 --- a/awx/main/tests/functional/test_rbac_core.py +++ b/awx/main/tests/functional/test_rbac_core.py @@ -91,15 +91,10 @@ def test_team_symantics(organization, team, alice): assert organization.accessible_by(alice, {'read': True}) is False team.member_role.children.add(organization.auditor_role) assert organization.accessible_by(alice, {'read': True}) is False - team.users.add(alice) + team.member_role.members.add(alice) assert organization.accessible_by(alice, {'read': True}) is True - team.users.remove(alice) + team.member_role.members.remove(alice) assert organization.accessible_by(alice, {'read': True}) is False - alice.teams.add(team) - assert organization.accessible_by(alice, {'read': True}) is True - alice.teams.remove(team) - assert organization.accessible_by(alice, {'read': True}) is False - @pytest.mark.django_db def test_auto_m2m_adjuments(organization, project, alice): From 9ea0803b457a20ec31fb8039297e9cd3f89594e8 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 29 Feb 2016 12:05:23 -0500 Subject: [PATCH 119/297] added role list and deletion to projects object --- awx/ui/client/legacy-styles/lists.less | 2 +- awx/ui/client/src/access/main.js | 12 +++ awx/ui/client/src/access/roleList.block.less | 71 ++++++++++++++ .../client/src/access/roleList.directive.js | 44 +++++++++ .../client/src/access/roleList.partial.html | 13 +++ awx/ui/client/src/app.js | 40 ++++++-- awx/ui/client/src/controllers/Projects.js | 1 - awx/ui/client/src/filters.js | 1 - awx/ui/client/src/forms/Projects.js | 85 ++++------------ .../client/src/helpers/PaginationHelpers.js | 2 +- awx/ui/client/src/helpers/related-search.js | 5 + .../authentication.service.js | 4 +- .../client/src/main-menu/main-menu.block.less | 4 + awx/ui/client/src/shared/form-generator.js | 98 ++++++++++++------- awx/ui/client/src/shared/generator-helpers.js | 6 +- .../list-generator/list-generator.factory.js | 7 +- awx/ui/client/src/shared/prompt/prompt.less | 5 + 17 files changed, 278 insertions(+), 122 deletions(-) create mode 100644 awx/ui/client/src/access/main.js create mode 100644 awx/ui/client/src/access/roleList.block.less create mode 100644 awx/ui/client/src/access/roleList.directive.js create mode 100644 awx/ui/client/src/access/roleList.partial.html diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index ba6adba673..0929fd4a28 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -39,7 +39,7 @@ table, tbody { border-top-left-radius: 5px; } -.List-tableHeader:last-of-type { +.List-tableHeader--actions { border-top-right-radius: 5px; text-align: right; } diff --git a/awx/ui/client/src/access/main.js b/awx/ui/client/src/access/main.js new file mode 100644 index 0000000000..5b7063938b --- /dev/null +++ b/awx/ui/client/src/access/main.js @@ -0,0 +1,12 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import roleList from './roleList.directive'; +import addPermissions from './addPermissions/main'; + +export default + angular.module('access', []) + .directive('roleList', roleList); diff --git a/awx/ui/client/src/access/roleList.block.less b/awx/ui/client/src/access/roleList.block.less new file mode 100644 index 0000000000..2e006791cf --- /dev/null +++ b/awx/ui/client/src/access/roleList.block.less @@ -0,0 +1,71 @@ +/** @define RoleList */ + +.RoleList { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.RoleList-tagContainer { + display: flex; + max-width: 100%; +} + +.RoleList-tag { + border-radius: 5px; + padding: 2px 10px; + margin: 4px 0px; + border: 1px solid #e1e1e1; + font-size: 12px; + color: #848992; + text-transform: uppercase; + background-color: #fff; + margin-right: 5px; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.RoleList-tag--deletable { + margin-right: 0px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-right: 0; + max-wdith: ~"calc(100% - 23px)"; +} + +.RoleList-deleteContainer { + border: 1px solid #e1e1e1; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + padding: 0 5px; + margin: 4px 0px; + margin-right: 5px; + align-items: center; + display: flex; + cursor: pointer; +} + +.RoleList-tagDelete { + font-size: 13px; + color: #b7b7b7; +} + +.RoleList-name { + flex: initial; + max-width: 100%; +} + +.RoleList-tag--deletable > .RoleList-name { + max-width: ~"calc(100% - 23px)"; +} + +.RoleList-deleteContainer:hover, { + border-color: #ff5850; + background-color: #ff5850; +} + +.RoleList-deleteContainer:hover > .RoleList-tagDelete { + color: #fff; +} diff --git a/awx/ui/client/src/access/roleList.directive.js b/awx/ui/client/src/access/roleList.directive.js new file mode 100644 index 0000000000..376a00f085 --- /dev/null +++ b/awx/ui/client/src/access/roleList.directive.js @@ -0,0 +1,44 @@ +/* jshint unused: vars */ +export default + [ 'templateUrl', + function(templateUrl) { + return { + restrict: 'E', + scope: false, + templateUrl: templateUrl('access/roleList'), + link: function(scope, element, attrs) { + // given a list of roles (things like "project + // auditor") which are pulled from two different + // places in summary fields, and creates a + // concatenated/sorted list + scope.roles = [] + .concat(scope.permission.summary_fields + .direct_access.map(function(i) { + return { + name: i.role.name, + roleId: i.role.id, + resourceName: i.role.resource_name, + explicit: true + }; + })) + .concat(scope.permission.summary_fields + .indirect_access.map(function(i) { + return { + name: i.role.name, + roleId: i.role.id, + explicit: false + }; + })) + .sort(function(a, b) { + if (a.name + .toLowerCase() > b.name + .toLowerCase()) { + return 1; + } else { + return -1; + } + }); + } + }; + } + ]; diff --git a/awx/ui/client/src/access/roleList.partial.html b/awx/ui/client/src/access/roleList.partial.html new file mode 100644 index 0000000000..bc49322d45 --- /dev/null +++ b/awx/ui/client/src/access/roleList.partial.html @@ -0,0 +1,13 @@ +
+
+ {{ role.name }} +
+
+ +
+
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index a23e803de2..974e0e885e 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -31,6 +31,7 @@ import permissions from './permissions/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; import notifications from './notifications/main'; +import access from './access/main'; // modules import about from './about/main'; @@ -101,6 +102,7 @@ var tower = angular.module('Tower', [ jobDetail.name, notifications.name, standardOut.name, + access.name, 'templates', 'Utilities', 'OrganizationFormDefinition', @@ -884,16 +886,38 @@ var tower = angular.module('Tower', [ }]); }]) - .run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', '$state', 'CheckLicense', - '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket', - 'LoadConfig', 'Store', 'ShowSocketHelp', 'pendoService', - function ( - $q, $compile, $cookieStore, $rootScope, $log, $state, CheckLicense, - $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket, - LoadConfig, Store, ShowSocketHelp, pendoService) - { + .run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket', + 'LoadConfig', 'Store', 'ShowSocketHelp', 'AboutAnsibleHelp', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', + function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket, + LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService, Prompt, Rest, Wait, ProcessErrors, $state) { var sock; + $rootScope.deletePermission = function (user, role, userName, + roleName, resourceName) { + var action = function () { + $('#prompt-modal').modal('hide'); + Wait('start'); + var url = "/api/v1/users/" + user + "/roles/"; + Rest.setUrl(url); + Rest.post({"disassociate": true, "id": role}) + .success(function () { + Wait('stop'); + $rootScope.$broadcast("refreshList", "permission"); + }) + .error(function (data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + }); + }; + + Prompt({ + hdr: 'Remove Role from ' + resourceName, + body: '
Confirm the removal of the ' + roleName + ' role associated with ' + userName + '.
', + action: action, + actionText: 'REMOVE' + }); + }; + function activateTab() { // Make the correct tab active var base = $location.path().replace(/^\//, '').split('/')[0]; diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 911ebcf356..c75cc5193f 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -649,7 +649,6 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; } } - relatedSets = form.relatedSets(data.related); diff --git a/awx/ui/client/src/filters.js b/awx/ui/client/src/filters.js index da0123cad7..e6549bb779 100644 --- a/awx/ui/client/src/filters.js +++ b/awx/ui/client/src/filters.js @@ -7,7 +7,6 @@ import sanitizeFilter from './shared/xss-sanitizer.filter'; import capitalizeFilter from './shared/capitalize.filter'; import longDateFilter from './shared/long-date.filter'; - export { sanitizeFilter, capitalizeFilter, diff --git a/awx/ui/client/src/forms/Projects.js b/awx/ui/client/src/forms/Projects.js index dbf04c457f..aa39d4bd7d 100644 --- a/awx/ui/client/src/forms/Projects.js +++ b/awx/ui/client/src/forms/Projects.js @@ -278,83 +278,38 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) } } }, - - schedules: { + permissions: { type: 'collection', - title: 'Schedules', - iterator: 'schedule', + title: 'Permissions', + iterator: 'permission', index: false, open: false, - + searchType: 'select', actions: { - refresh: { - mode: 'all', - awToolTip: "Refresh the page", - ngClick: "refreshSchedules()", - actionClass: 'btn List-buttonDefault', - buttonContent: 'REFRESH', - ngHide: 'scheduleLoading == false && schedule_active_search == false && schedule_total_rows < 1' - }, add: { - mode: 'all', - ngClick: 'addSchedule()', - awToolTip: 'Add a new schedule', + ngClick: "addPermission", + label: 'Add', + awToolTip: 'Add a permission', actionClass: 'btn List-buttonSubmit', buttonContent: '+ ADD' } }, + fields: { - name: { + username: { key: true, - label: 'Name', - ngClick: "editSchedule(schedule.id)", - columnClass: "col-md-3 col-sm-3 col-xs-3" + label: 'User', + linkBase: 'users', + class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' }, - dtstart: { - label: 'First Run', - filter: "longDate", - searchable: false, - columnClass: "col-md-2 col-sm-3 hidden-xs" - }, - next_run: { - label: 'Next Run', - filter: "longDate", - searchable: false, - columnClass: "col-md-2 col-sm-3 col-xs-3" - }, - dtend: { - label: 'Final Run', - filter: "longDate", - searchable: false, - columnClass: "col-md-2 col-sm-3 hidden-xs" - } - }, - fieldActions: { - "play": { - mode: "all", - ngClick: "toggleSchedule($event, schedule.id)", - awToolTip: "{{ schedule.play_tip }}", - dataTipWatch: "schedule.play_tip", - iconClass: "{{ 'fa icon-schedule-enabled-' + schedule.enabled }}", - dataPlacement: "top" - }, - edit: { - label: 'Edit', - ngClick: "editSchedule(schedule.id)", - icon: 'icon-edit', - awToolTip: 'Edit schedule', - dataPlacement: 'top' - }, - "delete": { - label: 'Delete', - ngClick: "deleteSchedule(schedule.id)", - icon: 'icon-trash', - awToolTip: 'Delete schedule', - dataPlacement: 'top' + role: { + label: 'Role', + type: 'role', + noSort: true, + class: 'col-lg-9 col-md-9 col-sm-9 col-xs-8' } } } - }, relatedSets: function(urls) { @@ -363,9 +318,9 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) iterator: 'organization', url: urls.organizations }, - schedules: { - iterator: 'schedule', - url: urls.schedules + permissions: { + iterator: 'permission', + url: urls.resource_access_list } }; } diff --git a/awx/ui/client/src/helpers/PaginationHelpers.js b/awx/ui/client/src/helpers/PaginationHelpers.js index 2b131c2dc0..4bbc46cd03 100644 --- a/awx/ui/client/src/helpers/PaginationHelpers.js +++ b/awx/ui/client/src/helpers/PaginationHelpers.js @@ -134,7 +134,7 @@ export default } else if (mode === 'lookup') { scope[iterator + '_page_size'] = 5; } else { - scope[iterator + '_page_size'] = 20; + scope[iterator + '_page_size'] = 2; } scope.getPage = function (page, set, iterator) { diff --git a/awx/ui/client/src/helpers/related-search.js b/awx/ui/client/src/helpers/related-search.js index cad094d349..04bb72f071 100644 --- a/awx/ui/client/src/helpers/related-search.js +++ b/awx/ui/client/src/helpers/related-search.js @@ -230,10 +230,15 @@ export default url += (url.match(/\/$/)) ? '?' : '&'; url += scope[iterator + 'SearchParams']; url += (scope[iterator + '_page_size']) ? '&page_size=' + scope[iterator + '_page_size'] : ""; + scope[iterator + '_active_search'] = true; RefreshRelated({ scope: scope, set: set, iterator: iterator, url: url }); }; + scope.$on("refreshList", function(e, iterator) { + scope.search(iterator); + }); + scope.sort = function (iterator, fld) { var sort_order, icon, direction, set; diff --git a/awx/ui/client/src/login/authenticationServices/authentication.service.js b/awx/ui/client/src/login/authenticationServices/authentication.service.js index d648bdd1eb..912ada41a8 100644 --- a/awx/ui/client/src/login/authenticationServices/authentication.service.js +++ b/awx/ui/client/src/login/authenticationServices/authentication.service.js @@ -85,7 +85,9 @@ export default } Store('sessionTime', x); - $rootScope.lastUser = $cookieStore.get('current_user').id; + if ($cookieStore.get('current_user')) { + $rootScope.lastUser = $cookieStore.get('current_user').id; + } $cookieStore.remove('token_expires'); $cookieStore.remove('current_user'); $cookieStore.remove('token'); diff --git a/awx/ui/client/src/main-menu/main-menu.block.less b/awx/ui/client/src/main-menu/main-menu.block.less index 5e9b342c2c..d64b5de3d3 100644 --- a/awx/ui/client/src/main-menu/main-menu.block.less +++ b/awx/ui/client/src/main-menu/main-menu.block.less @@ -136,6 +136,10 @@ .MainMenu-itemText--username { padding-left: 13px; margin-top: -4px; + max-width: 85px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .MainMenu-itemImage { diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 693a090f68..c7187d7106 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -1723,32 +1723,52 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "\n"; html += (collection.index === undefined || collection.index !== false) ? "#\n" : ""; for (fld in collection.fields) { - html += "" + - collection.fields[fld].label; - html += " " } else { - html += "fa fa-sort"; + html += ">"; } - html += "\">\n"; + + + html += collection.fields[fld].label; + + if (!collection.fields[fld].noSort) { + html += " " + } + + html += "\n"; + } + if (collection.fieldActions) { + html += "Actions\n"; } - html += "Actions\n"; html += "\n"; html += ""; html += "\n"; - html += "\n"; if (collection.index === undefined || collection.index !== false) { - html += "{{ $index + ((" + collection.iterator + "_page - 1) * " + + html += "{{ $index + ((" + collection.iterator + "_page - 1) * " + collection.iterator + "_page_size) + 1 }}.\n"; } cnt = 1; @@ -1765,31 +1785,33 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } // Row level actions - html += ""; - for (act in collection.fieldActions) { - fAction = collection.fieldActions[act]; - html += ""; } - // html += SelectIcon({ action: act }); - //html += (fAction.label) ? " " + fAction.label + "": ""; - html += ""; + html += ""; + html += "\n"; } - html += ""; - html += "\n"; // Message for loading html += "\n"; diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index 1fc8880e63..44bff438d9 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -449,6 +449,8 @@ angular.module('GeneratorHelpers', [systemStatus.name]) if (field.type !== undefined && field.type === 'DropDown') { html = DropDown(params); + } else if (field.type === 'role') { + html += ""; } else if (field.type === 'badgeCount') { html = BadgeCount(params); } else if (field.type === 'badgeOnly') { @@ -520,7 +522,7 @@ angular.module('GeneratorHelpers', [systemStatus.name]) list: list, field: field, fld: fld, - base: base + base: field.linkBase || base }) + ' '; }); } @@ -532,7 +534,7 @@ angular.module('GeneratorHelpers', [systemStatus.name]) list: list, field: field, fld: fld, - base: base + base: field.linkBase || base }); } } diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 5071dfee54..de6aed9081 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -665,10 +665,9 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate } } if (options.mode === 'select') { - html += "Select"; - } - else if (options.mode === 'edit' && list.fieldActions) { - html += "Select"; + } else if (options.mode === 'edit' && list.fieldActions) { + html += ""; html += (list.fieldActions.label === undefined || list.fieldActions.label) ? "Actions" : ""; diff --git a/awx/ui/client/src/shared/prompt/prompt.less b/awx/ui/client/src/shared/prompt/prompt.less index e0e5ef0c30..5c491b64e3 100644 --- a/awx/ui/client/src/shared/prompt/prompt.less +++ b/awx/ui/client/src/shared/prompt/prompt.less @@ -8,3 +8,8 @@ .Prompt-bodyTarget { color: @default-data-txt; } + +.Prompt-emphasis { + font-weight: bold; + text-transform: uppercase; +} From 2f1ee901e4035cf1cb4600b9e932e990e392ec19 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 29 Feb 2016 13:12:13 -0500 Subject: [PATCH 120/297] fixes to pr --- awx/ui/client/src/app.js | 8 ++++---- awx/ui/client/src/helpers/PaginationHelpers.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 974e0e885e..892c5f66b6 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -887,9 +887,9 @@ var tower = angular.module('Tower', [ }]) .run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket', - 'LoadConfig', 'Store', 'ShowSocketHelp', 'AboutAnsibleHelp', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', + 'LoadConfig', 'Store', 'ShowSocketHelp', 'AboutAnsibleHelp', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', 'GetBasePath', function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket, - LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService, Prompt, Rest, Wait, ProcessErrors, $state) { + LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService, Prompt, Rest, Wait, ProcessErrors, $state, GetBasePath) { var sock; $rootScope.deletePermission = function (user, role, userName, @@ -897,7 +897,7 @@ var tower = angular.module('Tower', [ var action = function () { $('#prompt-modal').modal('hide'); Wait('start'); - var url = "/api/v1/users/" + user + "/roles/"; + var url = GetBasePath("users") + user + "/roles/"; Rest.setUrl(url); Rest.post({"disassociate": true, "id": role}) .success(function () { @@ -906,7 +906,7 @@ var tower = angular.module('Tower', [ }) .error(function (data, status) { ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + msg: 'Could not disacssociate user from role. Call to ' + url + ' failed. DELETE returned status: ' + status }); }); }; diff --git a/awx/ui/client/src/helpers/PaginationHelpers.js b/awx/ui/client/src/helpers/PaginationHelpers.js index 4bbc46cd03..2b131c2dc0 100644 --- a/awx/ui/client/src/helpers/PaginationHelpers.js +++ b/awx/ui/client/src/helpers/PaginationHelpers.js @@ -134,7 +134,7 @@ export default } else if (mode === 'lookup') { scope[iterator + '_page_size'] = 5; } else { - scope[iterator + '_page_size'] = 2; + scope[iterator + '_page_size'] = 20; } scope.getPage = function (page, set, iterator) { From 6b37054621ef3ae0ab82db37374de3a502f2efd8 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 4 Mar 2016 00:39:03 -0500 Subject: [PATCH 121/297] working commit rbac add permissions --- awx/ui/client/legacy-styles/ansible-ui.less | 25 ++ awx/ui/client/legacy-styles/lists.less | 5 + .../addPermissions/addPermissions.block.less | 144 ++++++++++++ .../addPermissions.controller.js | 217 ++++++++++++++++++ .../addPermissions.directive.js | 51 ++++ .../addPermissions.partial.html | 105 +++++++++ .../client/src/access/addPermissions/main.js | 15 ++ .../addPermissions/roleSelect.directive.js | 25 ++ .../src/access/addPermissions/teams/main.js | 13 ++ .../teams/permissionsTeams.directive.js | 44 ++++ .../teams/permissionsTeams.list.js | 27 +++ .../src/access/addPermissions/users/main.js | 13 ++ .../users/permissionsUsers.directive.js | 44 ++++ .../users/permissionsUsers.list.js | 37 +++ awx/ui/client/src/access/main.js | 2 +- awx/ui/client/src/app.js | 4 + awx/ui/client/src/controllers/Teams.js | 48 ++-- awx/ui/client/src/controllers/Users.js | 55 ++--- awx/ui/client/src/forms/Users.js | 126 +++++----- .../client/src/helpers/PaginationHelpers.js | 2 +- awx/ui/client/src/shared/Utilities.js | 4 +- awx/ui/client/src/shared/form-generator.js | 4 +- awx/ui/client/src/shared/generator-helpers.js | 2 + .../list-generator/list-generator.factory.js | 8 +- .../select-list-item.directive.js | 6 +- 25 files changed, 906 insertions(+), 120 deletions(-) create mode 100644 awx/ui/client/src/access/addPermissions/addPermissions.block.less create mode 100644 awx/ui/client/src/access/addPermissions/addPermissions.controller.js create mode 100644 awx/ui/client/src/access/addPermissions/addPermissions.directive.js create mode 100644 awx/ui/client/src/access/addPermissions/addPermissions.partial.html create mode 100644 awx/ui/client/src/access/addPermissions/main.js create mode 100644 awx/ui/client/src/access/addPermissions/roleSelect.directive.js create mode 100644 awx/ui/client/src/access/addPermissions/teams/main.js create mode 100644 awx/ui/client/src/access/addPermissions/teams/permissionsTeams.directive.js create mode 100644 awx/ui/client/src/access/addPermissions/teams/permissionsTeams.list.js create mode 100644 awx/ui/client/src/access/addPermissions/users/main.js create mode 100644 awx/ui/client/src/access/addPermissions/users/permissionsUsers.directive.js create mode 100644 awx/ui/client/src/access/addPermissions/users/permissionsUsers.list.js diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index 717ab7bd30..742e9a24c1 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -634,6 +634,13 @@ dd { box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 5px rgba(255, 88, 80, 0.6); } + .form-control.ng-dirty.ng-invalid + .select2 .select2-selection, + .form-control.ng-dirty.ng-invalid + .select2 .select2-selection:focus { + border-color: rgba(255, 88, 80, 0.8) !important; + outline: 0 !important; + box-shadow: none !important; + } + .form-control.ng-dirty.ng-pristine { border-color: @default-second-border; box-shadow: none; @@ -2008,15 +2015,33 @@ tr td button i { box-shadow: none; } +.form-control + .select2 .select2-selection { + border-color: @default-second-border !important; + background-color: #f6f6f6 !important; + color: @default-data-txt !important; + transition: border-color 0.3s !important; + box-shadow: none !important; +} + .form-control:active, .form-control:focus { box-shadow: none; border-color: #167ec4; } +.form-control:active + .select2 .select2-selection, .form-control:focus + .select2 .select2-selection { + box-shadow: none !important; + border-color: #167ec4 !important; +} + .form-control.ng-dirty.ng-invalid, .form-control.ng-dirty.ng-invalid:focus { box-shadow: none; } +.form-control.ng-dirty.ng-invalid + .select2 .select2-selection, .form-control.ng-dirty.ng-invalid:focus + .select2 .select2-selection { + box-shadow: none !important; +} + + .error { opacity: 1; transition: opacity 0.2s; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 0929fd4a28..2e6f5e7b0c 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -320,6 +320,11 @@ table, tbody { height: 34px; } +.List-searchWidget--compact { + max-width: ~"calc(100% - 91px)"; + margin-top: 10px; +} + .List-searchRow { margin-bottom: 20px; } diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.block.less b/awx/ui/client/src/access/addPermissions/addPermissions.block.less new file mode 100644 index 0000000000..06e666e248 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/addPermissions.block.less @@ -0,0 +1,144 @@ +@import "../../shared/branding/colors.default.less"; + +/** @define AddPermissions */ +.AddPermissions { + position: absolute; + top: 0; + width: 100%; + height: 100%; +} + +.AddPermissions-content { + max-width: 750px !important; +} + +.AddPermissions-header { + padding: 20px; + padding-bottom: 10px; + padding-top: 15px; +} + +.AddPermissions-body { + padding-top: 0px !important; + max-height: 70vh; + overflow: scroll; +} + +.AddPermissions-footer { + padding-top: 20px !important; +} + +.AddPermissions-list .List-searchRow { + height: 0px; +} + +.AddPermissions-list .List-searchWidget { + height: 66px; +} + +.AddPermissions-list .List-tableHeader:last-child { + border-top-right-radius: 5px; +} + +.AddPermissions-list select-all { + display: none; +} + +.AddPermissions-title { + margin-top: 5px; + margin-bottom: 20px; +} + +.AddPermissions-buttons { + margin-left: auto; + margin-bottom: 20px; +} + +.AddPermissions-directions { + margin-top: 10px; + margin-bottom: 20px; + color: #848992; +} + +.AddPermissions-directionNumber { + font-size: 14px; + font-weight: bold; + border-radius: 50%; + background-color: #ebebeb; + padding-left: 6px; + padding-right: 1px; + padding-bottom: 3px; + margin-right: 10px; +} + +.AddPermissions-separator { + margin-top: 20px; + margin-bottom: 20px; + width: 100%; + border-bottom: 1px solid #e1e1e1; +} + +.AddPermissions-roleRow { + display: flex; + margin-bottom: 10px; + align-items: center; +} + +.AddPermissions-roleName { + width: 30%; + padding-right: 10px; + display: flex; + align-items: center; +} + +.AddPermissions-roleNameVal { + font-size: 14px; + max-width: ~"calc(100% - 46px)"; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.AddPermissions-roleType { + border-radius: 5px; + padding: 0px 6px; + border: 1px solid #e1e1e1; + font-size: 10px; + color: #848992; + text-transform: uppercase; + background-color: #fff; + margin-left: 6px; +} + +.AddPermissions-roleSelect { + width: ~"calc(70% - 40px)"; + margin-right: 20px; +} + +.AddPermissions-roleSelect .Form-dropDown { + height: inherit !important; +} + +.AddPermissions-roleRemove { + border-radius: 50%; + padding: 3px; + line-height: 11px; + padding-left: 5px; + padding-right: 5px; + color: #b7b7b7; + background-color: #fafafa; + border: 0; +} + +.AddPermissions-roleRemove:hover { + background-color: #ff5850; + color: #fff; +} + +.AddPermissions-selectHide { + display: none; +} + +.AddPermissions .select2-search__field { + text-transform: uppercase; +} diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js new file mode 100644 index 0000000000..24f152d1d7 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js @@ -0,0 +1,217 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/** + * @ngdoc function + * @name controllers.function:Authentication + * @description + * Controller for handling /#/login and /#/logout routes. + * + * Tower (app.js) verifies the user is authenticated and that the user session is not expired. If either condition is not true, + * the user is redirected to /#/login and the Authentication controller. + * + * Methods for checking the session state are found in [js/shared/AuthService.js](/static/docs/api/shared.function:AuthService), which is referenced here as Authorization. + * + * #Login Modal Dialog + * + * The modal dialog prompting for username and password is found in templates/ui/index.html. + *``` + * + * + *``` + * HTML for the login form is generated, compiled and injected into
by the controller. This is done to associate the form with the controller's scope. Because + *
is outside of the ng-view container, it gets associated with $rootScope by default. In the controller we create a new scope using $rootScope.$new() and associate + * that with the login form. Doing this each time the controller is instantiated insures the form is clean and not pre-populated with a prior user's username and password. + * + * Just before the release of 2.0 a bug was discovered where clicking logout and then immediately clicking login without providing a username and password would successfully log + * the user back into Tower. Implementing the above approach fixed this, forcing a new username/password to be entered each time the login dialog appears. + * + * #Login Workflow + * + * When the the login button is clicked, the following occurs: + * + * - Call Authorization.retrieveToken(username, password) - sends a POST request to /api/v1/authtoken to get a new token value. + * - Call Authorization.setToken(token, expires) to store the token and exipration time in a session cookie. + * - Start the expiration timer by calling the init() method of [js/shared/Timer.js](/static/docs/api/shared.function:Timer) + * - Get user informaton by calling Authorization.getUser() - sends a GET request to /api/v1/me + * - Store user information in the session cookie by calling Authorization.setUser(). + * - Get the Tower license by calling Authorization.getLicense() - sends a GET request to /api/vi/config + * - Stores the license object in local storage by calling Authorization.setLicense(). This adds the Tower version and a tested flag to the license object. The tested flag is initially set to false. + * + * Note that there is a session timer kept on the server side as well as the client side. Each time an API request is made, Tower (in app.js) calls + * Timer.isExpired(). This verifies the UI does not think the session is expired, and if not, moves the expiration time into the future. The number of + * seconds between API calls before a session is considered expired is set in config.js as session_timeout. + * + * @Usage + * This is usage information. + */ + +export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (rootScope, scope, GetBasePath, Rest, $q) { + scope.allSelected = []; + + // the object permissions are being added to + scope.object = scope[scope.$parent.list + .iterator + "_obj"]; + + // array for all possible roles for the object + scope.roles = Object + .keys(scope.object.summary_fields.roles) + .map(function(key) { + return { + value: scope.object.summary_fields + .roles[key].id, + label: scope.object.summary_fields + .roles[key].name }; + }); + + // handle form tabs + scope.toggleFormTabs = function(list) { + scope.usersSelected = (list === 'users'); + scope.teamsSelected = !scope.usersSelected; + }; + + // TODO: manually handle selection/deselection + // of user/team checkboxes + scope.$on("selectedOrDeselected", function(e, val) { + val = val.value; + if (val.isSelected) { + scope.allSelected = scope.allSelected.filter(function(i) { + return (!(val.id === i.id && val.type === i.type)); + }); + } else { + var name; + + if (val.type === "user") { + name = (val.first_name && + val.last_name) ? + val.first_name + " " + + val.last_name : + val.username; + } else { + name = val.name; + } + + scope.allSelected.push({ + name: name, + type: val.type, + roles: [], + id: val.id + }); + } + }); + + scope.$on("itemsSelected", function(e, inList) { + scope.updateLists = scope.allSelected.filter(function(inMemory) { + var notInList = true; + inList.forEach(function(val) { + if (inMemory.id === val.id && + inMemory.type === val.type) { + notInList = false; + } + }); + return notInList; + }); + }); + + scope.$watch("updateLists", function(toUpdate) { + (toUpdate || []).forEach(function(obj) { + var elemScope = angular + .element("#" + + obj.type + "s_table #" + obj.id + + ".List-tableRow input") + .scope() + if (elemScope) { + elemScope.isSelected = true; + } + }); + + delete scope.updateLists; + }); + + // create array of users/teams + // scope.$watchGroup(['selectedUsers', 'selectedTeams'], + // function(val) { + // scope.allSelected = (val[0] || []) + // .map(function(i) { + // var roles = i.roles || []; + // var name = (i.first_name && + // i.last_name) ? + // i.first_name + " " + + // i.last_name : + // i.username; + // + // return { + // name: name, + // type: "user", + // roles: roles, + // id: i.id + // }; + // }).concat((val[1] || []) + // .map(function(i) { + // var roles = i.roles || []; + // + // return { + // name: i.name, + // type: "team", + // roles: roles, + // id: i.id + // }; + // })); + // }); + + // remove selected user/team + scope.removeObject = function(obj) { + var elemScope = angular + .element("#" + + obj.type + "s_table #" + obj.id + ".List-tableRow input") + .scope() + if (elemScope) { + elemScope.isSelected = false; + } + + scope.allSelected = scope.allSelected.filter(function(i) { + return (!(obj.id === i.id && obj.type === i.type)); + }); + }; + + // update post url list + scope.$watch("allSelected", function(val) { + scope.posts = _ + .flatten((val || []) + .map(function (owner) { + var url = GetBasePath(owner.type + "s") + "/" + owner.id + + "/roles/"; + + return (owner.roles || []) + .map(function (role) { + return {url: url, + id: role.value}; + }); + })); + }, true); + + // post roles to api + scope.updatePermissions = function() { + var requests = scope.posts + .map(function(post) { + Rest.setUrl(post.url); + return Rest.post({"id": post.id}); + }); + + $q.all(requests) + .then(function (responses) { + rootScope.$broadcast("refreshList", "permission"); + scope.closeModal(); + }, function (error) { + // TODO: request(s) errored out. Call process errors + }); + }; +}]; diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.directive.js b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js new file mode 100644 index 0000000000..7a631f19a7 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js @@ -0,0 +1,51 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ +import addPermissionsController from './addPermissions.controller'; + +/* jshint unused: vars */ +export default + [ 'templateUrl', + 'Wait', + function(templateUrl, Wait) { + return { + restrict: 'E', + scope: true, + controller: addPermissionsController, + templateUrl: templateUrl('access/addPermissions/addPermissions'), + link: function(scope, element, attrs, ctrl) { + scope.toggleFormTabs('users'); + + $("body").append(element); + + Wait('start'); + + scope.$broadcast("linkLists"); + + setTimeout(function() { + $('#add-permissions-modal').modal("show"); + }, 200); + + $('.modal[aria-hidden=false]').each(function () { + if ($(this).attr('id') !== 'add-permissions-modal') { + $(this).modal('hide'); + } + }); + + scope.closeModal = function() { + $('#add-permissions-modal').on('hidden.bs.modal', + function () { + $('.AddPermissions').remove(); + }); + $('#add-permissions-modal').modal('hide'); + }; + + Wait('stop'); + + window.scrollTo(0,0); + } + }; + } + ]; diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html new file mode 100644 index 0000000000..68eb34cffa --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html @@ -0,0 +1,105 @@ + diff --git a/awx/ui/client/src/access/addPermissions/main.js b/awx/ui/client/src/access/addPermissions/main.js new file mode 100644 index 0000000000..001aa08b59 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/main.js @@ -0,0 +1,15 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import addPermissionsDirective from './addPermissions.directive'; +import roleSelect from './roleSelect.directive'; +import teamsPermissions from './teams/main'; +import usersPermissions from './users/main'; + +export default + angular.module('AddPermissions', [teamsPermissions.name, usersPermissions.name]) + .directive('addPermissions', addPermissionsDirective) + .directive('roleSelect', roleSelect); diff --git a/awx/ui/client/src/access/addPermissions/roleSelect.directive.js b/awx/ui/client/src/access/addPermissions/roleSelect.directive.js new file mode 100644 index 0000000000..c11dbe0e67 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/roleSelect.directive.js @@ -0,0 +1,25 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/* jshint unused: vars */ +export default + [ + 'CreateSelect2', + function(CreateSelect2) { + return { + restrict: 'E', + scope: false, + template: '', + link: function(scope, element, attrs, ctrl) { + CreateSelect2({ + element: '.roleSelect2', + multiple: true, + placeholder: 'Select roles' + }); + } + }; + } + ]; diff --git a/awx/ui/client/src/access/addPermissions/teams/main.js b/awx/ui/client/src/access/addPermissions/teams/main.js new file mode 100644 index 0000000000..ccba15fe86 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/teams/main.js @@ -0,0 +1,13 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import teamsDirective from './permissionsTeams.directive'; +import teamsList from './permissionsTeams.list'; + +export default + angular.module('PermissionsTeams', []) + .directive('addPermissionsTeams', teamsDirective) + .factory('addPermissionsTeamsList', teamsList); diff --git a/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.directive.js b/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.directive.js new file mode 100644 index 0000000000..158aeb1a23 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.directive.js @@ -0,0 +1,44 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/* jshint unused: vars */ +export default + ['addPermissionsTeamsList', 'generateList', 'GetBasePath', 'SelectionInit', 'SearchInit', + 'PaginateInit', function(addPermissionsTeamsList, generateList, + GetBasePath, SelectionInit, SearchInit, PaginateInit) { + return { + restrict: 'E', + scope: { + }, + template: "
", + link: function(scope, element, attrs, ctrl) { + scope.$on("linkLists", function() { + var generator = generateList, + list = addPermissionsTeamsList, + url = GetBasePath("teams"), + set = "teams", + id = "addPermissionsTeamsList", + mode = "edit"; + + scope.$watch("selectedItems", function() { + scope.$emit("itemsSelected", scope.selectedItems); + }); + + generator.inject(list, { id: id, + title: false, mode: mode, scope: scope }); + + SearchInit({ scope: scope, set: set, + list: list, url: url }); + + PaginateInit({ scope: scope, + list: list, url: url, pageSize: 5 }); + + scope.search(list.iterator); + }); + } + }; + } + ]; diff --git a/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.list.js b/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.list.js new file mode 100644 index 0000000000..dc30bfbaf5 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/teams/permissionsTeams.list.js @@ -0,0 +1,27 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + + export default function() { + return { + + name: 'teams', + iterator: 'team', + listTitleBadge: false, + multiSelect: true, + multiSelectExtended: true, + index: false, + hover: true, + + fields: { + name: { + key: true, + label: 'name' + }, + }, + + }; +} diff --git a/awx/ui/client/src/access/addPermissions/users/main.js b/awx/ui/client/src/access/addPermissions/users/main.js new file mode 100644 index 0000000000..e565e6c410 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/users/main.js @@ -0,0 +1,13 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import usersDirective from './permissionsUsers.directive'; +import usersList from './permissionsUsers.list'; + +export default + angular.module('PermissionsUsers', []) + .directive('addPermissionsUsers', usersDirective) + .factory('addPermissionsUsersList', usersList); diff --git a/awx/ui/client/src/access/addPermissions/users/permissionsUsers.directive.js b/awx/ui/client/src/access/addPermissions/users/permissionsUsers.directive.js new file mode 100644 index 0000000000..aa36c9c7eb --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/users/permissionsUsers.directive.js @@ -0,0 +1,44 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/* jshint unused: vars */ +export default + ['addPermissionsUsersList', 'generateList', 'GetBasePath', 'SelectionInit', 'SearchInit', + 'PaginateInit', function(addPermissionsUsersList, generateList, + GetBasePath, SelectionInit, SearchInit, PaginateInit) { + return { + restrict: 'E', + scope: { + }, + template: "
", + link: function(scope, element, attrs, ctrl) { + scope.$on("linkLists", function() { + var generator = generateList, + list = addPermissionsUsersList, + url = GetBasePath("users") + "?is_superuser=false", + set = "users", + id = "addPermissionsUsersList", + mode = "edit"; + + scope.$watch("selectedItems", function() { + scope.$emit("itemsSelected", scope.selectedItems); + }); + + generator.inject(list, { id: id, + title: false, mode: mode, scope: scope }); + + SearchInit({ scope: scope, set: set, + list: list, url: url }); + + PaginateInit({ scope: scope, + list: list, url: url, pageSize: 5 }); + + scope.search(list.iterator); + }); + } + }; + } + ]; diff --git a/awx/ui/client/src/access/addPermissions/users/permissionsUsers.list.js b/awx/ui/client/src/access/addPermissions/users/permissionsUsers.list.js new file mode 100644 index 0000000000..ced865e944 --- /dev/null +++ b/awx/ui/client/src/access/addPermissions/users/permissionsUsers.list.js @@ -0,0 +1,37 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + + export default function() { + return { + + name: 'users', + iterator: 'user', + title: false, + listTitleBadge: false, + multiSelect: true, + multiSelectExtended: true, + index: false, + hover: true, + + fields: { + first_name: { + label: 'First Name', + columnClass: 'col-md-3 col-sm-3 hidden-xs' + }, + last_name: { + label: 'Last Name', + columnClass: 'col-md-3 col-sm-3 hidden-xs' + }, + username: { + key: true, + label: 'Username', + columnClass: 'col-md-3 col-sm-3 col-xs-9' + }, + }, + + }; +} diff --git a/awx/ui/client/src/access/main.js b/awx/ui/client/src/access/main.js index 5b7063938b..084fe5ef87 100644 --- a/awx/ui/client/src/access/main.js +++ b/awx/ui/client/src/access/main.js @@ -8,5 +8,5 @@ import roleList from './roleList.directive'; import addPermissions from './addPermissions/main'; export default - angular.module('access', []) + angular.module('access', [addPermissions.name]) .directive('roleList', roleList); diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 892c5f66b6..14e955f423 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -892,6 +892,10 @@ var tower = angular.module('Tower', [ LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService, Prompt, Rest, Wait, ProcessErrors, $state, GetBasePath) { var sock; + $rootScope.addPermission = function (scope) { + $compile("")(scope); + } + $rootScope.deletePermission = function (user, role, userName, roleName, resourceName) { var action = function () { diff --git a/awx/ui/client/src/controllers/Teams.js b/awx/ui/client/src/controllers/Teams.js index 7d71bd8ee2..0bb7e9e2d4 100644 --- a/awx/ui/client/src/controllers/Teams.js +++ b/awx/ui/client/src/controllers/Teams.js @@ -210,36 +210,40 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $scope.$emit("RefreshTeamsList"); // return a promise from the options request with the permission type choices (including adhoc) as a param - var permissionsChoice = fieldChoices({ - scope: $scope, - url: 'api/v1/' + base + '/' + id + '/permissions/', - field: 'permission_type' - }); + // var permissionsChoice = fieldChoices({ + // scope: $scope, + // url: 'api/v1/' + base + '/' + id + '/permissions/', + // field: 'permission_type' + // }); - // manipulate the choices from the options request to be set on - // scope and be usable by the list form - permissionsChoice.then(function (choices) { - choices = - fieldLabels({ - choices: choices - }); - _.map(choices, function(n, key) { - $scope.permission_label[key] = n; - }); - }); + // // manipulate the choices from the options request to be set on + // // scope and be usable by the list form + // permissionsChoice.then(function (choices) { + // choices = + // fieldLabels({ + // choices: choices + // }); + // _.map(choices, function(n, key) { + // $scope.permission_label[key] = n; + // }); + // }); // manipulate the choices from the options request to be usable // by the search option for permission_type, you can't inject the // list until this is done! - permissionsChoice.then(function (choices) { - form.related.permissions.fields.permission_type.searchOptions = - permissionsSearchSelect({ - choices: choices - }); + // permissionsChoice.then(function (choices) { + // form.related.permissions.fields.permission_type.searchOptions = + // permissionsSearchSelect({ + // choices: choices + // }); generator.inject(form, { mode: 'edit', related: true, scope: $scope }); generator.reset(); $scope.$emit('loadTeam'); - }); + // }); + + generator.inject(form, { mode: 'edit', related: true, scope: $scope }); + generator.reset(); + $scope.$emit('loadTeam'); $scope.team_id = id; diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index f6b6b74a13..c17d6456f5 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -240,37 +240,38 @@ export function UsersEdit($scope, $rootScope, $compile, $location, $log, $scope.$emit("RefreshUsersList"); - // return a promise from the options request with the permission type choices (including adhoc) as a param - var permissionsChoice = fieldChoices({ - scope: $scope, - url: 'api/v1/' + base + '/' + id + '/permissions/', - field: 'permission_type' - }); - - // manipulate the choices from the options request to be set on - // scope and be usable by the list form - permissionsChoice.then(function (choices) { - choices = - fieldLabels({ - choices: choices - }); - _.map(choices, function(n, key) { - $scope.permission_label[key] = n; - }); - }); + // // return a promise from the options request with the permission type choices (including adhoc) as a param + // var permissionsChoice = fieldChoices({ + // scope: $scope, + // url: 'api/v1/' + base + '/' + id + '/permissions/', + // field: 'permission_type' + // }); + // + // // manipulate the choices from the options request to be set on + // // scope and be usable by the list form + // permissionsChoice.then(function (choices) { + // choices = + // fieldLabels({ + // choices: choices + // }); + // _.map(choices, function(n, key) { + // $scope.permission_label[key] = n; + // }); + // }); // manipulate the choices from the options request to be usable // by the search option for permission_type, you can't inject the // list until this is done! - permissionsChoice.then(function (choices) { - form.related.permissions.fields.permission_type.searchOptions = - permissionsSearchSelect({ - choices: choices - }); - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - generator.reset(); - $scope.$emit("loadForm"); - }); + // permissionsChoice.then(function (choices) { + // form.related.permissions.fields.permission_type.searchOptions = + // permissionsSearchSelect({ + // choices: choices + // }); + // }); + + generator.inject(form, { mode: 'edit', related: true, scope: $scope }); + generator.reset(); + $scope.$emit("loadForm"); if ($scope.removeFormReady) { $scope.removeFormReady(); diff --git a/awx/ui/client/src/forms/Users.js b/awx/ui/client/src/forms/Users.js index 5d87cac2db..820f86a8cf 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -158,69 +158,69 @@ export default } }, - permissions: { - type: 'collection', - title: 'Permissions', - iterator: 'permission', - open: false, - index: false, - - actions: { - add: { - ngClick: "add('permissions')", - label: 'Add', - awToolTip: 'Add a permission for this user', - ngShow: 'PermissionAddAllowed', - actionClass: 'btn List-buttonSubmit', - buttonContent: '+ ADD' - } - }, - - fields: { - name: { - key: true, - label: 'Name', - ngClick: "edit('permissions', permission.id, permission.name)" - }, - inventory: { - label: 'Inventory', - sourceModel: 'inventory', - sourceField: 'name', - ngBind: 'permission.summary_fields.inventory.name' - }, - project: { - label: 'Project', - sourceModel: 'project', - sourceField: 'name', - ngBind: 'permission.summary_fields.project.name' - }, - permission_type: { - label: 'Permission', - ngBind: 'getPermissionText()', - searchType: 'select' - } - }, - - fieldActions: { - edit: { - label: 'Edit', - ngClick: "edit('permissions', permission.id, permission.name)", - icon: 'icon-edit', - awToolTip: 'Edit the permission', - 'class': 'btn btn-default' - }, - - "delete": { - label: 'Delete', - ngClick: "delete('permissions', permission.id, permission.name, 'permission')", - icon: 'icon-trash', - "class": 'btn-danger', - awToolTip: 'Delete the permission', - ngShow: 'PermissionAddAllowed' - } - } - - }, + // permissions: { + // type: 'collection', + // title: 'Permissions', + // iterator: 'permission', + // open: false, + // index: false, + // + // actions: { + // add: { + // ngClick: "add('permissions')", + // label: 'Add', + // awToolTip: 'Add a permission for this user', + // ngShow: 'PermissionAddAllowed', + // actionClass: 'btn List-buttonSubmit', + // buttonContent: '+ ADD' + // } + // }, + // + // fields: { + // name: { + // key: true, + // label: 'Name', + // ngClick: "edit('permissions', permission.id, permission.name)" + // }, + // inventory: { + // label: 'Inventory', + // sourceModel: 'inventory', + // sourceField: 'name', + // ngBind: 'permission.summary_fields.inventory.name' + // }, + // project: { + // label: 'Project', + // sourceModel: 'project', + // sourceField: 'name', + // ngBind: 'permission.summary_fields.project.name' + // }, + // permission_type: { + // label: 'Permission', + // ngBind: 'getPermissionText()', + // searchType: 'select' + // } + // }, + // + // fieldActions: { + // edit: { + // label: 'Edit', + // ngClick: "edit('permissions', permission.id, permission.name)", + // icon: 'icon-edit', + // awToolTip: 'Edit the permission', + // 'class': 'btn btn-default' + // }, + // + // "delete": { + // label: 'Delete', + // ngClick: "delete('permissions', permission.id, permission.name, 'permission')", + // icon: 'icon-trash', + // "class": 'btn-danger', + // awToolTip: 'Delete the permission', + // ngShow: 'PermissionAddAllowed' + // } + // } + // + // }, admin_of_organizations: { // Assumes a plural name (e.g. things) type: 'collection', diff --git a/awx/ui/client/src/helpers/PaginationHelpers.js b/awx/ui/client/src/helpers/PaginationHelpers.js index 2b131c2dc0..2fd9d57bf2 100644 --- a/awx/ui/client/src/helpers/PaginationHelpers.js +++ b/awx/ui/client/src/helpers/PaginationHelpers.js @@ -32,7 +32,7 @@ export default // Which page are we on? if (Empty(next) && previous) { // no next page, but there is a previous page - scope[iterator + '_page'] = scope[iterator + '_num_pages']; + scope[iterator + '_page'] = /page=\d+/.test(previous) ? parseInt(previous.match(/page=(\d+)/)[1]) + 1 : 2; } else if (next && Empty(previous)) { // next page available, but no previous page scope[iterator + '_page'] = 1; diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 434526cd7d..7aeae22b6b 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -614,7 +614,8 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) var element = params.element, options = params.opts, - multiple = (params.multiple!==undefined) ? params.multiple : true; + multiple = (params.multiple!==undefined) ? params.multiple : true, + placeholder = params.placeholder; $.fn.select2.amd.require([ 'select2/utils', @@ -632,6 +633,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) }, Dropdown); $(element).select2({ + placeholder: placeholder, multiple: multiple, containerCssClass: 'Form-dropDown', width: '100%', diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index c7187d7106..8525ea7997 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -1548,7 +1548,9 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "
\n"; } diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index de6aed9081..2205f84390 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -416,6 +416,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate function buildTable() { var extraClasses = list['class']; var multiSelect = list.multiSelect ? 'multi-select-list' : null; + var multiSelectExtended = list.multiSelectExtended ? 'true' : 'false'; if (options.mode === 'summary') { extraClasses += ' table-summary'; @@ -425,7 +426,8 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate .attr('id', list.name + '_table') .addClass('List-table') .addClass(extraClasses) - .attr('multi-select-list', multiSelect); + .attr('multi-select-list', multiSelect) + .attr('is-extended', multiSelectExtended); } @@ -460,7 +462,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate } if (list.multiSelect) { - innerTable += ''; + innerTable += ''; } // Change layout if a lookup list, place radio buttons before labels @@ -609,7 +611,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate function buildSelectAll() { return $('') - .addClass('col-xs-1 select-column List-tableHeader') + .addClass('col-xs-1 select-column List-tableHeader List-staticColumn--smallStatus') .append( $('') .attr('selections-empty', 'selectedItems.length === 0') diff --git a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js index f701288090..fb90d045b8 100644 --- a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js @@ -30,7 +30,7 @@ export default item: '=item' }, require: '^multiSelectList', - template: '', + template: '', link: function(scope, element, attrs, multiSelectList) { scope.isSelected = false; @@ -52,6 +52,10 @@ export default multiSelectList.deregisterItem(scope.decoratedItem); }); + scope.userInteractionSelect = function() { + scope.$emit("selectedOrDeselected", scope.decoratedItem); + } + } }; }]; From 433ba95addeef37eb6861b153b82283b5641ec50 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 4 Mar 2016 11:14:01 -0500 Subject: [PATCH 122/297] working commit of rbac add permissions flow --- .../access/addPermissions/addPermissions.controller.js | 10 ++++++++++ .../access/addPermissions/addPermissions.partial.html | 1 - 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js index 24f152d1d7..39909e1170 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js +++ b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js @@ -72,6 +72,16 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r .roles[key].name }; }); + scope.roleKey = Object + .keys(scope.object.summary_fields.roles) + .map(function(key) { + return { + name: scope.object.summary_fields + .roles[key].name, + description: scope.object.summary_fields + .roles[key].description }; + }); + // handle form tabs scope.toggleFormTabs = function(list) { scope.usersSelected = (list === 'users'); diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html index 68eb34cffa..e9ef9b7d07 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html +++ b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html @@ -19,7 +19,6 @@
-
1. From 56364617fd47cc68a4a70eb21f2fd4cc4045665b Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 7 Mar 2016 11:19:43 -0500 Subject: [PATCH 123/297] clean up of ui rbac code --- .../addPermissions/addPermissions.block.less | 63 ++++++-- .../addPermissions.controller.js | 152 +++++------------- .../addPermissions.directive.js | 2 +- .../addPermissions.partial.html | 22 ++- .../addPermissionsList.directive.js} | 22 ++- .../addPermissions/addPermissionsList/main.js | 15 ++ .../permissionsTeams.list.js | 0 .../permissionsUsers.list.js | 0 .../client/src/access/addPermissions/main.js | 5 +- .../src/access/addPermissions/teams/main.js | 13 -- .../src/access/addPermissions/users/main.js | 13 -- .../users/permissionsUsers.directive.js | 44 ----- awx/ui/client/src/access/roleList.block.less | 17 +- 13 files changed, 145 insertions(+), 223 deletions(-) rename awx/ui/client/src/access/addPermissions/{teams/permissionsTeams.directive.js => addPermissionsList/addPermissionsList.directive.js} (61%) create mode 100644 awx/ui/client/src/access/addPermissions/addPermissionsList/main.js rename awx/ui/client/src/access/addPermissions/{teams => addPermissionsList}/permissionsTeams.list.js (100%) rename awx/ui/client/src/access/addPermissions/{users => addPermissionsList}/permissionsUsers.list.js (100%) delete mode 100644 awx/ui/client/src/access/addPermissions/teams/main.js delete mode 100644 awx/ui/client/src/access/addPermissions/users/main.js delete mode 100644 awx/ui/client/src/access/addPermissions/users/permissionsUsers.directive.js diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.block.less b/awx/ui/client/src/access/addPermissions/addPermissions.block.less index 06e666e248..3a49aef10e 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.block.less +++ b/awx/ui/client/src/access/addPermissions/addPermissions.block.less @@ -1,15 +1,39 @@ @import "../../shared/branding/colors.default.less"; /** @define AddPermissions */ -.AddPermissions { - position: absolute; + +.AddPermissions-backDrop { + width: 100vw; + height: 100vh; + position: fixed; top: 0; - width: 100%; - height: 100%; + left: 0; + z-index: 1041; + opacity: 0; + transition: 0.5s opacity; + background: @login-backdrop; + opacity: 0.2; +} + +.AddPermissions-dialog { + margin: 30px auto; + margin-top: 95px; } .AddPermissions-content { - max-width: 750px !important; + max-width: 750px; + margin-left: auto; + margin-right: auto; + border: 0; + box-shadow: none; + background-color: @login-bg; + border-radius: 4px; + opacity: 0; + transition: opacity 0.5s; + z-index: 1042; + position: relative; + opacity: 1; + transition: opacity 0.5s; } .AddPermissions-header { @@ -19,13 +43,20 @@ } .AddPermissions-body { - padding-top: 0px !important; + padding-left: 20px; + padding-right: 20px; + padding-top: 0px; max-height: 70vh; overflow: scroll; } .AddPermissions-footer { - padding-top: 20px !important; + display: flex; + flex-wrap: wrap-reverse; + align-items: center; + padding: 20px; + padding-bottom: 0px; + padding-top: 20px; } .AddPermissions-list .List-searchRow { @@ -64,7 +95,7 @@ font-size: 14px; font-weight: bold; border-radius: 50%; - background-color: #ebebeb; + background-color: @default-list-header-bg; padding-left: 6px; padding-right: 1px; padding-bottom: 3px; @@ -75,7 +106,7 @@ margin-top: 20px; margin-bottom: 20px; width: 100%; - border-bottom: 1px solid #e1e1e1; + border-bottom: 1px solid @default-second-border; } .AddPermissions-roleRow { @@ -102,11 +133,11 @@ .AddPermissions-roleType { border-radius: 5px; padding: 0px 6px; - border: 1px solid #e1e1e1; + border: 1px solid @default-second-border; font-size: 10px; - color: #848992; + color: @default-interface-txt; text-transform: uppercase; - background-color: #fff; + background-color: @default-bg; margin-left: 6px; } @@ -125,14 +156,14 @@ line-height: 11px; padding-left: 5px; padding-right: 5px; - color: #b7b7b7; - background-color: #fafafa; + color: @default-icon; + background-color: @default-tertiary-bg; border: 0; } .AddPermissions-roleRemove:hover { - background-color: #ff5850; - color: #fff; + background-color: @default-err; + color: @default-bg; } .AddPermissions-selectHide { diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js index 39909e1170..4e60995dec 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js +++ b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js @@ -6,55 +6,22 @@ /** * @ngdoc function - * @name controllers.function:Authentication + * @name controllers.function:Access * @description - * Controller for handling /#/login and /#/logout routes. - * - * Tower (app.js) verifies the user is authenticated and that the user session is not expired. If either condition is not true, - * the user is redirected to /#/login and the Authentication controller. - * - * Methods for checking the session state are found in [js/shared/AuthService.js](/static/docs/api/shared.function:AuthService), which is referenced here as Authorization. - * - * #Login Modal Dialog - * - * The modal dialog prompting for username and password is found in templates/ui/index.html. - *``` - * - * - *``` - * HTML for the login form is generated, compiled and injected into
by the controller. This is done to associate the form with the controller's scope. Because - *
is outside of the ng-view container, it gets associated with $rootScope by default. In the controller we create a new scope using $rootScope.$new() and associate - * that with the login form. Doing this each time the controller is instantiated insures the form is clean and not pre-populated with a prior user's username and password. - * - * Just before the release of 2.0 a bug was discovered where clicking logout and then immediately clicking login without providing a username and password would successfully log - * the user back into Tower. Implementing the above approach fixed this, forcing a new username/password to be entered each time the login dialog appears. - * - * #Login Workflow - * - * When the the login button is clicked, the following occurs: - * - * - Call Authorization.retrieveToken(username, password) - sends a POST request to /api/v1/authtoken to get a new token value. - * - Call Authorization.setToken(token, expires) to store the token and exipration time in a session cookie. - * - Start the expiration timer by calling the init() method of [js/shared/Timer.js](/static/docs/api/shared.function:Timer) - * - Get user informaton by calling Authorization.getUser() - sends a GET request to /api/v1/me - * - Store user information in the session cookie by calling Authorization.setUser(). - * - Get the Tower license by calling Authorization.getLicense() - sends a GET request to /api/vi/config - * - Stores the license object in local storage by calling Authorization.setLicense(). This adds the Tower version and a tested flag to the license object. The tested flag is initially set to false. - * - * Note that there is a session timer kept on the server side as well as the client side. Each time an API request is made, Tower (in app.js) calls - * Timer.isExpired(). This verifies the UI does not think the session is expired, and if not, moves the expiration time into the future. The number of - * seconds between API calls before a session is considered expired is set in config.js as session_timeout. - * - * @Usage - * This is usage information. + * Controller for handling permissions adding */ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (rootScope, scope, GetBasePath, Rest, $q) { + var manuallyUpdateChecklists = function(list, id, isSelected) { + var elemScope = angular + .element("#" + + list + "s_table #" + id + ".List-tableRow input") + .scope(); + if (elemScope) { + elemScope.isSelected = !!isSelected; + } + }; + scope.allSelected = []; // the object permissions are being added to @@ -72,6 +39,8 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r .roles[key].name }; }); + // TODO: get working with api + // array w roles and descriptions for key scope.roleKey = Object .keys(scope.object.summary_fields.roles) .map(function(key) { @@ -82,35 +51,36 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r .roles[key].description }; }); - // handle form tabs + // handle form tab changes scope.toggleFormTabs = function(list) { scope.usersSelected = (list === 'users'); scope.teamsSelected = !scope.usersSelected; }; - // TODO: manually handle selection/deselection - // of user/team checkboxes + // manually handle selection/deselection of user/team checkboxes scope.$on("selectedOrDeselected", function(e, val) { val = val.value; if (val.isSelected) { + // deselected, so remove from the allSelected list scope.allSelected = scope.allSelected.filter(function(i) { + // return all but the object who has the id and type + // of the element to deselect return (!(val.id === i.id && val.type === i.type)); }); } else { - var name; - - if (val.type === "user") { - name = (val.first_name && - val.last_name) ? - val.first_name + " " + - val.last_name : - val.username; - } else { - name = val.name; - } - + // selected, so add to the allSelected list scope.allSelected.push({ - name: name, + name: function() { + if (val.type === "user") { + return (val.first_name && + val.last_name) ? + val.first_name + " " + + val.last_name : + val.username; + } else { + return val .name; + } + }, type: val.type, roles: [], id: val.id @@ -118,10 +88,16 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r } }); + // used to handle changes to the itemsSelected scope var on "next page", + // "sorting etc." scope.$on("itemsSelected", function(e, inList) { + // compile a list of objects that needed to be checked in the lists scope.updateLists = scope.allSelected.filter(function(inMemory) { var notInList = true; inList.forEach(function(val) { + // if the object is part of the allSelected list and is + // selected, + // you don't need to add it updateLists if (inMemory.id === val.id && inMemory.type === val.type) { notInList = false; @@ -131,61 +107,19 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r }); }); + // handle changes to the updatedLists by manually selected those values in + // the UI scope.$watch("updateLists", function(toUpdate) { (toUpdate || []).forEach(function(obj) { - var elemScope = angular - .element("#" + - obj.type + "s_table #" + obj.id + - ".List-tableRow input") - .scope() - if (elemScope) { - elemScope.isSelected = true; - } + manuallyUpdateChecklists(obj.type, obj.id, true); }); delete scope.updateLists; }); - // create array of users/teams - // scope.$watchGroup(['selectedUsers', 'selectedTeams'], - // function(val) { - // scope.allSelected = (val[0] || []) - // .map(function(i) { - // var roles = i.roles || []; - // var name = (i.first_name && - // i.last_name) ? - // i.first_name + " " + - // i.last_name : - // i.username; - // - // return { - // name: name, - // type: "user", - // roles: roles, - // id: i.id - // }; - // }).concat((val[1] || []) - // .map(function(i) { - // var roles = i.roles || []; - // - // return { - // name: i.name, - // type: "team", - // roles: roles, - // id: i.id - // }; - // })); - // }); - // remove selected user/team scope.removeObject = function(obj) { - var elemScope = angular - .element("#" + - obj.type + "s_table #" + obj.id + ".List-tableRow input") - .scope() - if (elemScope) { - elemScope.isSelected = false; - } + manuallyUpdateChecklists(obj.type, obj.id, false); scope.allSelected = scope.allSelected.filter(function(i) { return (!(obj.id === i.id && obj.type === i.type)); @@ -197,7 +131,7 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r scope.posts = _ .flatten((val || []) .map(function (owner) { - var url = GetBasePath(owner.type + "s") + "/" + owner.id + + var url = GetBasePath(owner.type + "s") + owner.id + "/roles/"; return (owner.roles || []) @@ -217,7 +151,7 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', function (r }); $q.all(requests) - .then(function (responses) { + .then(function () { rootScope.$broadcast("refreshList", "permission"); scope.closeModal(); }, function (error) { diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.directive.js b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js index 7a631f19a7..c96f0e3701 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.directive.js +++ b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js @@ -17,7 +17,7 @@ export default templateUrl: templateUrl('access/addPermissions/addPermissions'), link: function(scope, element, attrs, ctrl) { scope.toggleFormTabs('users'); - + $("body").append(element); Wait('start'); diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html index e9ef9b7d07..dc824b7a3e 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html +++ b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html @@ -1,7 +1,7 @@ -