Merge pull request #922 from anoek/rbac

RBAC refactoring and first pass at ORMification
This commit is contained in:
Akita Noek
2016-02-12 10:19:30 -05:00
4 changed files with 169 additions and 193 deletions

View File

@@ -3,17 +3,26 @@
# Django # Django
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.db.models.signals import m2m_changed
from django.db import models from django.db import models
from django.db.models.fields.related import SingleRelatedObjectDescriptor from django.db.models.fields.related import (
from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor add_lazy_relation,
SingleRelatedObjectDescriptor,
ReverseSingleRelatedObjectDescriptor,
ManyRelatedObjectsDescriptor,
ReverseManyRelatedObjectsDescriptor,
)
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
# AWX # AWX
from awx.main.models.rbac import Resource, RolePermission, Role from awx.main.models.rbac import Resource, RolePermission, Role
__all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField'] __all__ = ['AutoOneToOneField', 'ImplicitResourceField', 'ImplicitRoleField']
# Based on AutoOneToOneField from django-annoying: # Based on AutoOneToOneField from django-annoying:
# https://bitbucket.org/offline/django-annoying/src/a0de8b294db3/annoying/fields.py # https://bitbucket.org/offline/django-annoying/src/a0de8b294db3/annoying/fields.py
@@ -44,38 +53,17 @@ 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): class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor):
"""Descriptor for access to the object from its related class.""" """Descriptor for access to the object from its related class."""
def __init__(self, parent_resource, *args, **kwargs): def __init__(self, *args, **kwargs):
self.parent_resource = parent_resource
super(ResourceFieldDescriptor, self).__init__(*args, **kwargs) super(ResourceFieldDescriptor, self).__init__(*args, **kwargs)
def __get__(self, instance, instance_type=None): def __get__(self, instance, instance_type=None):
resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type) resource = super(ResourceFieldDescriptor, self).__get__(instance, instance_type)
if resource: if resource:
return resource return resource
resource = Resource._default_manager.create() resource = Resource._default_manager.create(content_object=instance)
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) setattr(instance, self.field.name, resource)
instance.save(update_fields=[self.field.name,]) instance.save(update_fields=[self.field.name,])
return resource return resource
@@ -84,8 +72,7 @@ class ResourceFieldDescriptor(ReverseSingleRelatedObjectDescriptor):
class ImplicitResourceField(models.ForeignKey): class ImplicitResourceField(models.ForeignKey):
"""Creates an associated resource object if one doesn't already exist""" """Creates an associated resource object if one doesn't already exist"""
def __init__(self, parent_resource=None, *args, **kwargs): def __init__(self, *args, **kwargs):
self.parent_resource = parent_resource
kwargs.setdefault('to', 'Resource') kwargs.setdefault('to', 'Resource')
kwargs.setdefault('related_name', '+') kwargs.setdefault('related_name', '+')
kwargs.setdefault('null', 'True') kwargs.setdefault('null', 'True')
@@ -93,7 +80,7 @@ class ImplicitResourceField(models.ForeignKey):
def contribute_to_class(self, cls, name): 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)) setattr(cls, self.name, ResourceFieldDescriptor(self))
post_save.connect(self._save, cls, True) post_save.connect(self._save, cls, True)
def _save(self, instance, *args, **kwargs): def _save(self, instance, *args, **kwargs):
@@ -120,23 +107,38 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor):
if not self.role_name: if not self.role_name:
raise FieldError('Implicit role missing `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: if self.parent_role:
# Add all non-null parent roles as parents def resolve_field(obj, field):
if type(self.parent_role) is list: ret = []
for path in self.parent_role:
if path.startswith("singleton:"): field_components = field.split('.', 1)
parent = Role.singleton(path[10:]) if hasattr(obj, field_components[0]):
else: obj = getattr(obj, field_components[0])
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:])
else: else:
parent = resolve_field(instance, self.parent_role) return []
if parent:
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) role.parents.add(parent)
setattr(instance, self.field.name, role) setattr(instance, self.field.name, role)
instance.save(update_fields=[self.field.name,]) instance.save(update_fields=[self.field.name,])
@@ -192,6 +194,65 @@ class ImplicitRoleField(models.ForeignKey):
) )
) )
post_save.connect(self._save, cls, True) 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): def _save(self, instance, *args, **kwargs):
# Ensure that our field gets initialized after our first save # Ensure that our field gets initialized after our first save

View File

@@ -1,11 +1,13 @@
# Django # Django
from django.db import models 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 # AWX
from awx.main.models.rbac import RolePermission, Role, RoleHierarchy from awx.main.models.rbac import Resource
from awx.main.fields import ImplicitResourceField from awx.main.fields import ImplicitResourceField
__all__ = 'ResourceMixin' __all__ = 'ResourceMixin'
class ResourceMixin(models.Model): class ResourceMixin(models.Model):
@@ -29,56 +31,15 @@ class ResourceMixin(models.Model):
`myresource.get_permissions(user)`. `myresource.get_permissions(user)`.
''' '''
aggregate_where_clause = '' qs = Resource.objects.filter(
aggregates = '' content_type=ContentType.objects.get_for_model(cls),
group_clause = '' permissions__role__ancestors__members=user
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.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
}
]
) )
for perm in permissions:
qs = qs.annotate(**{'max_' + perm: Max('permissions__' + perm)})
qs = qs.filter(**{'max_' + perm: int(permissions[perm])})
return cls.objects.filter(resource__in=qs)
def get_permissions(self, user): def get_permissions(self, user):
@@ -96,46 +57,27 @@ class ResourceMixin(models.Model):
access. access.
''' '''
qs = user.__class__.objects.filter(id=user.id, roles__descendents__permissions__resource=self.resource)
with connection.cursor() as cursor: qs = qs.annotate(max_create = Max('roles__descendents__permissions__create'))
cursor.execute( qs = qs.annotate(max_read = Max('roles__descendents__permissions__read'))
''' qs = qs.annotate(max_write = Max('roles__descendents__permissions__write'))
SELECT qs = qs.annotate(max_update = Max('roles__descendents__permissions__update'))
MAX("create") as "create", qs = qs.annotate(max_delete = Max('roles__descendents__permissions__delete'))
MAX("read") as "read", qs = qs.annotate(max_scm_update = Max('roles__descendents__permissions__scm_update'))
MAX("write") as "write", qs = qs.annotate(max_execute = Max('roles__descendents__permissions__execute'))
MAX("update") as "update", qs = qs.annotate(max_use = Max('roles__descendents__permissions__use'))
MAX("delete") as "delete",
MAX("scm_update") as "scm_update",
MAX("execute") as "execute",
MAX("use") as "use"
FROM %(rbac_permission)s qs = qs.values('max_create', 'max_read', 'max_write', 'max_update',
LEFT JOIN %(rbac_role_hierachy)s 'max_delete', 'max_scm_update', 'max_execute', 'max_use')
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
)
WHERE %(rbac_permission)s.resource_id=%(resource_id)s res = qs.all()
GROUP BY %(rbac_role)s_members.user_id if len(res):
''' # strip away the 'max_' prefix
% return {k[4:]:v for k,v in res[0].items()}
{
'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 return None
def accessible_by(self, user, permissions): def accessible_by(self, user, permissions):
''' '''
Returns true if the user has all of the specified permissions Returns true if the user has all of the specified permissions

View File

@@ -7,11 +7,13 @@ import logging
# Django # Django
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
# AWX # AWX
from awx.main.models.base import * # noqa from awx.main.models.base import * # noqa
__all__ = ['Role', 'RolePermission', 'Resource', 'RoleHierarchy'] __all__ = ['Role', 'RolePermission', 'Resource']
logger = logging.getLogger('awx.main.models.rbac') 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) singleton_name = models.TextField(null=True, default=None, db_index=True, unique=True)
parents = models.ManyToManyField('Role', related_name='children') 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') 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): def save(self, *args, **kwargs):
super(Role, self).save(*args, **kwargs) super(Role, self).save(*args, **kwargs)
self.rebuild_role_hierarchy_cache() self.rebuild_role_ancestor_list()
def rebuild_role_hierarchy_cache(self): def rebuild_role_ancestor_list(self):
'Rebuilds the associated entries in the RoleHierarchy model' '''
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 You should never need to call this. Signal handlers should be calling
# parent's cached hierarchy being correct) this method when the role hierachy changes automatically.
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)]) 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) actual_ancestors.add(self.id)
if None in actual_ancestors:
# Compute what we have stored actual_ancestors.remove(None)
stored_ancestors = set([r.ancestor.id for r in RoleHierarchy.objects.filter(role__id=self.id)]) stored_ancestors = set(self.ancestors.all().values_list('id', flat=True))
# If it differs, update, and then update all of our children # If it differs, update, and then update all of our children
if actual_ancestors != stored_ancestors: if actual_ancestors != stored_ancestors:
RoleHierarchy.objects.filter(role__id=self.id).delete() for id in actual_ancestors - stored_ancestors:
for id in actual_ancestors: self.ancestors.add(Role.objects.get(id=id))
rh = RoleHierarchy(role=self, ancestor=Role.objects.get(id=id)) for id in stored_ancestors - actual_ancestors:
rh.save() self.ancestors.remove(Role.objects.get(id=id))
for child in self.children.all(): for child in self.children.all():
child.rebuild_role_hierarchy_cache() child.rebuild_role_ancestor_list()
def grant(self, resource, permissions): def grant(self, resource, permissions):
# take either the raw Resource or something that includes the ResourceMixin # take either the raw Resource or something that includes the ResourceMixin
@@ -85,22 +96,7 @@ class Role(CommonModelNameNotUnique):
return ret return ret
def is_ancestor_of(self, role): def is_ancestor_of(self, role):
return RoleHierarchy.objects.filter(role_id=role.id, ancestor_id=self.id).count() > 0 return role.ancestors.filter(id=self.id).exists()
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): class Resource(CommonModelNameNotUnique):
@@ -113,7 +109,9 @@ class Resource(CommonModelNameNotUnique):
verbose_name_plural = _('resources') verbose_name_plural = _('resources')
db_table = 'main_rbac_resources' db_table = 'main_rbac_resources'
parent = models.ForeignKey('Resource', related_name='children', null=True, default=None) 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): class RolePermission(CreatedModifiedModel):

View File

@@ -18,7 +18,6 @@ from crum.signals import current_user_getter
# AWX # AWX
from awx.main.models import * # noqa from awx.main.models import * # noqa
from awx.api.serializers 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 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.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
from awx.main.tasks import update_inventory_computed_fields from awx.main.tasks import update_inventory_computed_fields
@@ -116,37 +115,13 @@ def store_initial_active_state(sender, **kwargs):
else: else:
instance._saved_active_state = True 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: if reverse:
for id in pk_set: for id in pk_set:
model.objects.get(id=id).rebuild_role_hierarchy_cache() model.objects.get(id=id).rebuild_role_ancestor_list()
else: 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) pre_save.connect(store_initial_active_state, sender=Host)
post_save.connect(emit_update_inventory_on_created_or_deleted, 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_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job)
post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_job_event_detail, sender=JobEvent)
post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) 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_role_ancestor_list, Role.parents.through)
m2m_changed.connect(rebuild_group_parent_roles, Group.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 # Migrate hosts, groups to parent group(s) whenever a group is deleted or
# marked as inactive. # marked as inactive.