Skip to content

Commit

Permalink
Basic support for UUID primary keys working
Browse files Browse the repository at this point in the history
  • Loading branch information
AlanCoding committed Feb 1, 2024
1 parent e1c482d commit 447c743
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 83 deletions.
45 changes: 36 additions & 9 deletions ansible_base/rbac/caching.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
from collections import defaultdict
from typing import Optional
from uuid import UUID

from ansible_base.rbac.models import ObjectRole, RoleDefinition, RoleEvaluation
from ansible_base.rbac.models import ObjectRole, RoleDefinition, RoleEvaluation, RoleEvaluationUUID
from ansible_base.rbac.permission_registry import permission_registry
from ansible_base.rbac.prefetch import TypesPrefetch

Expand Down Expand Up @@ -79,11 +80,12 @@ def get_direct_team_member_roles(org_team_mapping: dict) -> dict[int, list[int]]
direct_member_roles = defaultdict(list)
for object_role in ObjectRole.objects.filter(role_definition__permissions__codename=permission_registry.team_permission).iterator():
if object_role.content_type_id == permission_registry.team_ct_id:
direct_member_roles[object_role.cache_id].append(object_role.id)
direct_member_roles[int(object_role.object_id)].append(object_role.id)
elif object_role.content_type_id == permission_registry.org_ct_id:
if object_role.cache_id not in org_team_mapping:
object_id = int(object_role.object_id)
if object_id not in org_team_mapping:
continue # this means the organization has no team but has member_team as a listed permission
for team_id in org_team_mapping[object_role.cache_id]:
for team_id in org_team_mapping[object_id]:
direct_member_roles[team_id].append(object_role.id)
else:
logger.warning(f'{object_role} gives {permission_registry.team_permission} to an invalid type')
Expand All @@ -107,11 +109,12 @@ def get_parent_teams_of_teams(org_team_mapping: dict) -> dict[int, list[int]]:
).prefetch_related('teams'):
for actor_team in object_role.teams.all():
if object_role.content_type_id == permission_registry.team_ct_id:
team_team_parents[object_role.cache_id].append(actor_team.id)
team_team_parents[int(object_role.object_id)].append(actor_team.id)
elif object_role.content_type_id == permission_registry.org_ct_id:
if object_role.cache_id not in org_team_mapping:
object_id = int(object_role.object_id)
if object_id not in org_team_mapping:
continue # again, means the organization has no team but has member_team as a listed permission
for team_id in org_team_mapping[object_role.cache_id]:
for team_id in org_team_mapping[object_id]:
team_team_parents[team_id].append(actor_team.id)
return team_team_parents

Expand Down Expand Up @@ -180,8 +183,32 @@ def compute_object_role_permissions(object_roles=None, types_prefetch=None):

if to_add:
logger.info(f'Adding {len(to_add)} object-permission records')
RoleEvaluation.objects.bulk_create(to_add)
to_add_int = []
to_add_uuid = []
for evaluation in to_add:
if isinstance(evaluation.object_id, int):
to_add_int.append(evaluation)
elif isinstance(evaluation.object_id, UUID):
to_add_uuid.append(evaluation)
else:
raise RuntimeError(f'Could not find a place in cache for {evaluation}')
if to_add_int:
RoleEvaluation.objects.bulk_create(to_add_int)
if to_add_uuid:
RoleEvaluationUUID.objects.bulk_create(to_add_uuid)

if to_delete:
logger.info(f'Deleting {len(to_delete)} object-permission records')
RoleEvaluation.objects.filter(id__in=to_delete).delete()
to_delete_int = []
to_delete_uuid = []
for evaluation_id, evaluation_type in to_delete:
if evaluation_type is int:
to_delete_int.append(evaluation_id)
elif evaluation_type is UUID:
to_delete_uuid.append(evaluation_id)
else:
raise RuntimeError(f'Unexpected type to delete {evaluation_id}-{evaluation_type}')
if to_delete_int:
RoleEvaluation.objects.filter(id__in=to_delete_int).delete()
if to_delete_uuid:
RoleEvaluationUUID.objects.filter(id__in=to_delete_uuid).delete()
10 changes: 5 additions & 5 deletions ansible_base/rbac/evaluations.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from django.conf import settings

from ansible_base.rbac import permission_registry
from ansible_base.rbac.models import ObjectRole, RoleEvaluation
from ansible_base.rbac.models import ObjectRole, get_evaluation_model

'''
The model RoleEvaluation is the authority for making any permission evaluations,
RoleEvaluation or RoleEvaluationUUID models are the authority for permission evaluations,
meaning, determining whether a user has a permission to an object.
Methods needed for producing querysets (of objects a user has a permission to
Expand Down Expand Up @@ -62,15 +62,15 @@ def __call__(self, user, codename='view', **kwargs):
full_codename = validate_codename_for_model(codename, self.cls)
if has_super_permission(user, codename):
return self.cls.objects.all()
return RoleEvaluation.accessible_objects(self.cls, user, full_codename, **kwargs)
return get_evaluation_model(self.cls).accessible_objects(self.cls, user, full_codename, **kwargs)


class AccessibleIdsDescriptor(BaseEvaluationDescriptor):
def __call__(self, user, codename, **kwargs):
full_codename = validate_codename_for_model(codename, self.cls)
if has_super_permission(user, codename):
return self.cls.objects.values_list('id', flat=True) # hopefully we never need this...
return RoleEvaluation.accessible_ids(self.cls, user, full_codename, **kwargs)
return get_evaluation_model(self.cls).accessible_ids(self.cls, user, full_codename, **kwargs)


def bound_has_obj_perm(self, obj, codename):
Expand All @@ -79,7 +79,7 @@ def bound_has_obj_perm(self, obj, codename):
return True
if full_codename in self.singleton_permissions():
return True
return RoleEvaluation.has_obj_perm(self, obj, full_codename)
return get_evaluation_model(obj).has_obj_perm(self, obj, full_codename)


def bound_singleton_permissions(self):
Expand Down
27 changes: 27 additions & 0 deletions ansible_base/rbac/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,31 @@ class Migration(migrations.Migration):
name='description',
field=models.TextField(blank=True, null=True),
),
migrations.CreateModel(
name='RoleEvaluationUUID',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('codename', models.TextField(help_text='The name of the permission, giving the action and the model, from the Django Permission model')),
('content_type_id', models.PositiveIntegerField()),
('object_id', models.UUIDField()),
('role', models.ForeignKey(
help_text='The object role that grants this form of permission',
on_delete=django.db.models.deletion.CASCADE,
related_name='permission_partials_uuid',
to='dab_rbac.objectrole'
)),
],
options={
'verbose_name_plural': 'role_object_permissions',
'indexes': [
models.Index(fields=['role', 'content_type_id', 'object_id'], name='dab_rbac_ro_role_id_237936_idx'),
models.Index(fields=['role', 'content_type_id', 'codename'], name='dab_rbac_ro_role_id_4fe905_idx')
],
},
),
migrations.AddConstraint(
model_name='roleevaluationuuid',
constraint=models.UniqueConstraint(
fields=('object_id', 'content_type_id', 'codename', 'role'), name='one_entry_per_object_permission_and_role_uuid'),
),
]
124 changes: 84 additions & 40 deletions ansible_base/rbac/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db import connection, models
from django.db.models.functions import Concat
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -214,14 +214,12 @@ class Meta:
def visible_items(cls, user):
# TODO: when and if it is available on needed models, replace representer with ansible_id
representer = Concat(models.F('object_id'), models.Value(':'), models.F('content_type_id'), output_field=models.CharField())
return cls.objects.annotate(tmp_id=representer).filter(
tmp_id__in=RoleEvaluation.objects.filter(role__in=user.has_roles.all()).values_list(representer)
)
return cls.objects.annotate(tmp_id=representer).filter(tmp_id__in=RoleEvaluation.objects.filter(role__in=user.has_roles.all()).values_list(representer))

@property
def cache_id(self):
"The ObjectRole GenericForeignKey is text, but cache needs to match models"
return int(self.object_id)
return RoleEvaluation._meta.get_field('object_id').to_python(self.object_id)


class AssignmentBase(CommonModel, ObjectRoleFields):
Expand Down Expand Up @@ -348,14 +346,19 @@ def expected_direct_permissions(self, types_prefetch=None):
permission_content_type = types_prefetch.get_content_type(permission.content_type_id)

if permission.content_type_id == self.content_type_id:
expected_evaluations.add((permission.codename, self.content_type_id, self.cache_id))
model = permission_content_type.model_class()
# ObjectRole.object_id is stored as text, we convert it to the model pk native type
object_id = model._meta.pk.to_python(self.object_id)
expected_evaluations.add((permission.codename, self.content_type_id, object_id))
elif permission.codename.startswith('add'):
model = permission_content_type.model_class()
role_child_models = set(cls for filter_path, cls in permission_registry.get_child_models(role_content_type.model))
if permission_content_type.model_class() not in role_child_models:
if model not in role_child_models:
# NOTE: this should also be validated when creating a role definition
logger.warning(f'{self} lists {permission.codename} for an object that is not a child object')
continue
expected_evaluations.add((permission.codename, self.content_type_id, self.cache_id))
object_id = model._meta.pk.to_python(self.object_id)
expected_evaluations.add((permission.codename, self.content_type_id, object_id))
else:
id_list = []
# fetching child objects of an organization is very performance sensitive
Expand All @@ -366,7 +369,8 @@ def expected_direct_permissions(self, types_prefetch=None):
# model must be in same app as organization
for filter_path, model in permission_registry.get_child_models(role_content_type.model):
if model._meta.model_name == permission_content_type.model:
id_list = model.objects.filter(**{filter_path: self.cache_id}).values_list('id', flat=True)
object_id = model._meta.pk.get_db_prep_value(self.object_id, connection)
id_list = model.objects.filter(**{filter_path: object_id}).values_list('id', flat=True)
cached_id_lists[permission.content_type_id] = list(id_list)
break
else:
Expand All @@ -381,6 +385,8 @@ def needed_cache_updates(self, types_prefetch=None):
existing_partials = dict()
for permission_partial in self.permission_partials.all():
existing_partials[permission_partial.obj_perm_id()] = permission_partial
for permission_partial in self.permission_partials_uuid.all():
existing_partials[permission_partial.obj_perm_id()] = permission_partial

expected_evaluations = self.expected_direct_permissions(types_prefetch)

Expand All @@ -392,17 +398,27 @@ def needed_cache_updates(self, types_prefetch=None):

to_delete = set()
for identifier in existing_set - expected_evaluations:
to_delete.add(existing_partials[identifier].id)
to_delete.add((existing_partials[identifier].id, type(identifier[-1])))

to_add = []
for codename, ct_id, obj_id in expected_evaluations - existing_set:
to_add.append(RoleEvaluation(codename=codename, content_type_id=ct_id, object_id=obj_id, role=self))
for codename, ct_id, obj_pk in expected_evaluations - existing_set:
to_add.append(RoleEvaluation(codename=codename, content_type_id=ct_id, object_id=obj_pk, role=self))

return (to_delete, to_add)


class RoleEvaluationMeta:
app_label = 'dab_rbac'
verbose_name_plural = _('role_object_permissions')
indexes = [
models.Index(fields=["role", "content_type_id", "object_id"]), # used by get_roles_on_resource
models.Index(fields=["role", "content_type_id", "codename"]), # used by accessible_objects
]
constraints = [models.UniqueConstraint(name='one_entry_per_object_permission_and_role', fields=['object_id', 'content_type_id', 'codename', 'role'])]


# COMPUTED DATA
class RoleEvaluation(models.Model):
class RoleEvaluationFields(models.Model):
"""
Cached data that shows what permissions an ObjectRole gives its owners
example:
Expand All @@ -421,40 +437,30 @@ class RoleEvaluation(models.Model):
"""

class Meta:
app_label = 'dab_rbac'
verbose_name_plural = _('role_object_permissions')
indexes = [
models.Index(fields=["role", "content_type_id", "object_id"]), # used by get_roles_on_resource
models.Index(fields=["role", "content_type_id", "codename"]), # used by accessible_objects
]
constraints = [models.UniqueConstraint(name='one_entry_per_object_permission_and_role', fields=['object_id', 'content_type_id', 'codename', 'role'])]
abstract = True

def __str__(self):
return (
f'RoleEvaluation(pk={self.id}, codename={self.codename}, object_id={self.object_id}, '
f'{self._meta.verbose_name.title()}(pk={self.id}, codename={self.codename}, object_id={self.object_id}, '
f'content_type_id={self.content_type_id}, role_id={self.role_id})'
)

def save(self, *args, **kwargs):
if self.id:
raise RuntimeError('RoleEvaluation model is immutable and only used internally')
raise RuntimeError(f'{self._meta.model_name} model is immutable and only used internally')
return super().save(*args, **kwargs)

role = models.ForeignKey(
ObjectRole, null=False, on_delete=models.CASCADE, related_name='permission_partials', help_text=_("The object role that grants this form of permission")
)
codename = models.TextField(null=False, help_text=_("The name of the permission, giving the action and the model, from the Django Permission model"))
# NOTE: we do not form object_id and content_type into a content_object, following from AWX practice
# this can be relaxed as we have comparative performance testing to confirm doing so does not affect permissions
content_type_id = models.PositiveIntegerField(null=False)
object_id = models.PositiveIntegerField(null=False)

def obj_perm_id(self):
"Used for in-memory hashing of the type of object permission this represents"
return (self.codename, self.content_type_id, self.object_id)

@staticmethod
def accessible_ids(cls, actor, codename, content_types=None):
@classmethod
def accessible_ids(eval_cls, cls, actor, codename, content_types=None):
"""
Corresponds to AWX accessible_pk_qs
Expand All @@ -471,24 +477,62 @@ def accessible_ids(cls, actor, codename, content_types=None):
filter_kwargs['content_type_id__in'] = content_types
else:
filter_kwargs['content_type_id'] = ContentType.objects.get_for_model(cls).id
return RoleEvaluation.objects.filter(**filter_kwargs).values_list('object_id').distinct()
return eval_cls.objects.filter(**filter_kwargs).values_list('object_id').distinct()

@staticmethod
def accessible_objects(cls, user, codename):
return cls.objects.filter(pk__in=RoleEvaluation.accessible_ids(cls, user, codename))
@classmethod
def accessible_objects(eval_cls, cls, user, codename):
return cls.objects.filter(pk__in=eval_cls.accessible_ids(cls, user, codename))

@staticmethod
def get_permissions(user, obj):
return RoleEvaluation.objects.filter(
role__in=user.has_roles.all(), content_type_id=ContentType.objects.get_for_model(obj).id, object_id=obj.id
).values_list('codename', flat=True)
@classmethod
def get_permissions(cls, user, obj):
return cls.objects.filter(role__in=user.has_roles.all(), content_type_id=ContentType.objects.get_for_model(obj).id, object_id=obj.id).values_list(
'codename', flat=True
)

@staticmethod
def has_obj_perm(user, obj, codename):
@classmethod
def has_obj_perm(cls, user, obj, codename):
"""
Note this behaves similar in function to the REST Framework has_object_permission
method on permission classes, but it is named differently to avoid unintentionally conflicting
"""
return RoleEvaluation.objects.filter(
return cls.objects.filter(
role__in=user.has_roles.all(), content_type_id=ContentType.objects.get_for_model(obj).id, object_id=obj.id, codename=codename
).exists()


class RoleEvaluation(RoleEvaluationFields):
class Meta(RoleEvaluationMeta):
pass

role = models.ForeignKey(
ObjectRole, null=False, on_delete=models.CASCADE, related_name='permission_partials', help_text=_("The object role that grants this form of permission")
)
object_id = models.PositiveIntegerField(null=False)


class RoleEvaluationUUID(RoleEvaluationFields):
"Cache for UUID type models"

class Meta(RoleEvaluationMeta):
constraints = [
models.UniqueConstraint(name='one_entry_per_object_permission_and_role_uuid', fields=['object_id', 'content_type_id', 'codename', 'role'])
]

role = models.ForeignKey(
ObjectRole,
null=False,
on_delete=models.CASCADE,
related_name='permission_partials_uuid',
help_text=_("The object role that grants this form of permission"),
)
object_id = models.UUIDField(null=False)


def get_evaluation_model(cls):
pk_field = cls._meta.pk
if isinstance(pk_field, models.IntegerField):
return RoleEvaluation
elif isinstance(pk_field, models.UUIDField):
return RoleEvaluationUUID
else:
raise RuntimeError(f'Model {cls._meta.model_name} primary key type of {pk_field} is not supported')
10 changes: 3 additions & 7 deletions ansible_base/rbac/triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,10 @@ def team_ancestor_roles(team):
"""
Return a queryset of all roles that directly or indirectly grant any form of permission to a team.
This is generally used when invalidating a team membership for one reason or another.
This assumes that teams and all team parent models have integer primary keys.
"""
return set(
ObjectRole.objects.filter(
permission_partials__in=RoleEvaluation.objects.filter(
codename=permission_registry.team_permission, object_id=team.id, content_type_id=permission_registry.team_ct_id
)
)
)
permission_kwargs = dict(codename=permission_registry.team_permission, object_id=team.id, content_type_id=permission_registry.team_ct_id)
return set(ObjectRole.objects.filter(permission_partials__in=RoleEvaluation.objects.filter(**permission_kwargs)))


def validate_assignment_enabled(actor, content_type, has_team_perm=False):
Expand Down
Loading

0 comments on commit 447c743

Please sign in to comment.