Skip to content

Commit 1a0c7b8

Browse files
committed
Allow making evaluations as querysets on remote models
1 parent fc8ad5a commit 1a0c7b8

File tree

10 files changed

+131
-23
lines changed

10 files changed

+131
-23
lines changed

ansible_base/rbac/evaluations.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional
1+
from typing import Iterable, Optional, Type
22

33
from django.conf import settings
44
from django.contrib.auth.models import AnonymousUser
@@ -10,6 +10,8 @@
1010
from ansible_base.rbac.models import DABPermission, RoleDefinition, get_evaluation_model
1111
from ansible_base.rbac.validators import validate_codename_for_model
1212

13+
from .remote import RemoteObject
14+
1315
"""
1416
RoleEvaluation or RoleEvaluationUUID models are the authority for permission evaluations,
1517
meaning, determining whether a user has a permission to an object.
@@ -86,7 +88,7 @@ def __call__(self, actor, codename: str = 'view', queryset: Optional[QuerySet] =
8688

8789

8890
class AccessibleIdsDescriptor(BaseEvaluationDescriptor):
89-
def __call__(self, actor, codename: str = 'view', content_types=None, cast_field=None) -> QuerySet:
91+
def __call__(self, actor, codename: str = 'view', content_types: Optional[Iterable[int]] = None, cast_field=None) -> QuerySet:
9092
full_codename = validate_codename_for_model(codename, self.cls)
9193
if isinstance(actor, AnonymousUser):
9294
return self.cls.objects.none().values_list()
@@ -98,6 +100,25 @@ def __call__(self, actor, codename: str = 'view', content_types=None, cast_field
98100
return get_evaluation_model(self.cls).accessible_ids(self.cls, actor, full_codename, content_types=content_types, cast_field=cast_field)
99101

100102

103+
def remote_obj_id_qs(actor, remote_cls: Type[RemoteObject], codename: str = 'view', content_types: Optional[Iterable[int]] = None, cast_field=None) -> QuerySet:
104+
"""Returns a queryset of ids for a remote object"""
105+
full_codename = validate_codename_for_model(codename, remote_cls)
106+
evaluation_model = get_evaluation_model(remote_cls)
107+
if isinstance(actor, AnonymousUser):
108+
return evaluation_model.objects.none().values_list('object_id')
109+
if actor._meta.model_name == 'user' and has_super_permission(actor, full_codename):
110+
# Return all known objects on this server, some objects on remote server may not be known
111+
if content_types:
112+
filter_kwargs = {'content_type_id__in': content_types}
113+
else:
114+
filter_kwargs = {'content_type': remote_cls.get_ct_from_type()}
115+
if cast_field is None:
116+
return evaluation_model.objects.filter(**filter_kwargs).values_list('object_id').distinct()
117+
else:
118+
return evaluation_model.objects.filter(**filter_kwargs).values_list(Cast('object_id', output_field=cast_field)).distinct()
119+
return evaluation_model.accessible_ids(remote_cls, actor, full_codename, content_types=content_types, cast_field=cast_field)
120+
121+
101122
def bound_has_obj_perm(self, obj, codename) -> bool:
102123
if not permission_registry.is_registered(obj):
103124
raise ValidationError(f'Object of {obj._meta.model_name} type is not registered with DAB RBAC')

ansible_base/rbac/management/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ def sync_DAB_permissions(verbosity=2, using=DEFAULT_DB_ALIAS, apps=global_apps):
8282
# a list of the ones we're going to create.
8383
all_perms = set(Permission.objects.using(using).filter(content_type__in=ctypes).values_list("content_type", "codename"))
8484

85-
# TODO: add api_slug field when added
8685
perms = []
8786
for ct, (codename, name) in searched_perms:
8887
if (ct.pk, codename) not in all_perms:
@@ -91,6 +90,7 @@ def sync_DAB_permissions(verbosity=2, using=DEFAULT_DB_ALIAS, apps=global_apps):
9190
permission.codename = codename
9291
permission.name = name
9392
permission.content_type = ct
93+
permission.api_slug = f'{ct.service}.{codename}'
9494
perms.append(permission)
9595

9696
Permission.objects.using(using).bulk_create(perms)

ansible_base/rbac/management/create_types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Type
33

44
from django.apps import apps as global_apps
5-
from django.db import DEFAULT_DB_ALIAS, models
5+
from django.db import DEFAULT_DB_ALIAS, connection, models
66

77
from ansible_base.rbac import permission_registry
88
from ansible_base.rbac.remote import get_resource_prefix
@@ -34,7 +34,6 @@ def create_DAB_contenttypes(
3434

3535
content_types = get_local_DAB_contenttypes(using, DABContentType)
3636

37-
# TODO: add api_slug field when added
3837
ct_data = []
3938
for model in permission_registry.all_registered_models:
4039
service = get_resource_prefix(model)
@@ -44,6 +43,8 @@ def create_DAB_contenttypes(
4443
service=service,
4544
app_label=model._meta.app_label,
4645
model=model._meta.model_name,
46+
api_slug=f'{service}.{model._meta.model_name}',
47+
pk_field_type=model._meta.pk.db_type(connection),
4748
)
4849
# To make usage earier in a transitional period, we will set the content type
4950
# of any new entries created here to the id of its corresponding ContentType

ansible_base/rbac/migrations/0004_remote_permissions_additions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,20 @@ class Migration(migrations.Migration):
159159
model_name='roleevaluationuuid',
160160
name='dab_rbac_ro_role_id_4fe905_idx',
161161
),
162+
# Fields unique to DAB RBAC and not generally shared with ContentType or Permission
163+
migrations.AddField(
164+
model_name='dabcontenttype',
165+
name='api_slug',
166+
field=models.CharField(default='', help_text='String to use for references to this type from other models in the API.', max_length=201),
167+
),
168+
migrations.AddField(
169+
model_name='dabcontenttype',
170+
name='pk_field_type',
171+
field=models.CharField(default='integer', help_text='Database field type of the primary key field of the model, relevant for interal logic tracking permissions.', max_length=100),
172+
),
173+
migrations.AddField(
174+
model_name='dabpermission',
175+
name='api_slug',
176+
field=models.CharField(default='', help_text='String to use for references to this type from other models in the API.', max_length=201),
177+
),
162178
]

ansible_base/rbac/models/__init__.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.db import connection
2+
import inspect
23

4+
from ..remote import RemoteObject
35
from .content_type import DABContentType
46
from .permission import DABPermission
57
from .role import ObjectRole, RoleDefinition, RoleEvaluation, RoleEvaluationUUID, RoleTeamAssignment, RoleUserAssignment
@@ -18,10 +20,14 @@
1820

1921

2022
def get_evaluation_model(cls):
21-
pk_field = cls._meta.pk
22-
# For proxy models, including django-polymorphic, use the id field from parent table
23-
# we accomplish this by inspecting the raw database type of the field
24-
pk_db_type = pk_field.db_type(connection)
23+
if (inspect.isclass(cls) and issubclass(cls, RemoteObject)) or isinstance(cls, RemoteObject):
24+
# For remote models, we save the pk type in the database specifically for use here
25+
pk_db_type = cls.get_ct_from_type().pk_field_type
26+
else:
27+
pk_field = cls._meta.pk
28+
# For proxy models, including django-polymorphic, use the id field from parent table
29+
# we accomplish this by inspecting the raw database type of the field
30+
pk_db_type = pk_field.db_type(connection)
2531
for eval_cls in (RoleEvaluation, RoleEvaluationUUID):
2632
if pk_db_type == eval_cls._meta.get_field('object_id').db_type(connection):
2733
return eval_cls

ansible_base/rbac/models/content_type.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any, Dict, Optional, Sequence, Tuple, Type, Union
44

55
from django.apps import apps
6+
from django.db import connection
67
from django.db import models as django_models
78
from django.db.models.options import Options
89
from django.utils.translation import gettext_lazy as _
@@ -26,7 +27,6 @@ def clear_cache(self) -> None:
2627
self._cache.clear()
2728

2829
def create(self, *args: Any, **kwargs: Any) -> "DABContentType":
29-
# TODO: set api_slug field
3030
obj = super().create(*args, **kwargs)
3131
self._add_to_cache(self.db, obj)
3232
return obj
@@ -58,7 +58,7 @@ def get_for_model(
5858
self._add_to_cache(self.db, ct)
5959
return ct
6060
elif inspect.isclass(model) and issubclass(model, RemoteObject):
61-
ct = self.get_by_natural_key(model.type_data)
61+
ct = self.get_by_natural_key(model._meta.service, model._meta.app_label, model._meta.model_name)
6262
self._add_to_cache(self.db, ct)
6363
return ct
6464

@@ -77,6 +77,8 @@ def get_for_model(
7777
service=service,
7878
app_label=opts.app_label,
7979
model=opts.model_name,
80+
api_slug=f'{service}.{opts.model_name}',
81+
pk_field_type=model._meta.pk.db_type(connection),
8082
)
8183
self._add_to_cache(self.db, ct)
8284
return ct
@@ -132,7 +134,13 @@ def get_for_models(
132134
self._add_to_cache(self.db, ct)
133135
# Named it service_create to not shadown variable from prior loop
134136
for (service_create, app_label, model_name), opts_models in needed_opts.items():
135-
ct = self.create(service=service_create, app_label=app_label, model=model_name)
137+
if opts_models:
138+
pk_field_type = opts_models[0]._meta.pk.db_type(connection)
139+
else:
140+
pk_field_type = 'integer'
141+
ct = self.create(
142+
service=service_create, app_label=app_label, model=model_name, api_slug=f'{service_create}.{model_name}', pk_field_type=pk_field_type
143+
)
136144
self._add_to_cache(self.db, ct)
137145
for model in opts_models:
138146
results[model] = ct
@@ -188,6 +196,16 @@ class DABContentType(django_models.Model):
188196
on_delete=django_models.SET_NULL,
189197
related_name='child_content_types',
190198
)
199+
api_slug = django_models.CharField(
200+
max_length=201, # combines service and model fields with a period in-between
201+
default='', # will be set by the saving or creation logic
202+
help_text=_("String to use for references to this type from other models in the API."),
203+
)
204+
pk_field_type = django_models.CharField(
205+
max_length=100,
206+
default='integer',
207+
help_text=_("Database field type of the primary key field of the model, relevant for interal logic tracking permissions."),
208+
)
191209

192210
objects = DABContentTypeManager()
193211

@@ -216,6 +234,15 @@ def app_labeled_name(self) -> str:
216234
return self.model
217235
return f"{model._meta.app_config.verbose_name} | {model._meta.verbose_name}"
218236

237+
def save(self, *args, **kwargs):
238+
# Set the api_slug field if it is not synchronized to other fields
239+
api_slug = f'{self.service}.{self.model}'
240+
if api_slug != self.api_slug:
241+
self.api_slug = api_slug
242+
if update_fields := kwargs.get('update_fields', []):
243+
update_fields.append('api_slug')
244+
return super().save(*args, **kwargs)
245+
219246
def model_class(self) -> Union[Type[django_models.Model], Type[RemoteObject]]:
220247
"""Return the model class or a stand-in.
221248

ansible_base/rbac/models/permission.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class DABPermission(models.Model):
2727
)
2828
),
2929
)
30+
api_slug = models.CharField(
31+
max_length=201, # combines content_type.service and codename fields with a period in-between
32+
default='',
33+
help_text=_("String to use for references to this type from other models in the API."),
34+
)
3035

3136
class Meta:
3237
app_label = 'dab_rbac'
@@ -37,3 +42,12 @@ class Meta:
3742

3843
def __str__(self):
3944
return f"<{self.__class__.__name__}: {self.codename}>"
45+
46+
def save(self, *args, **kwargs):
47+
# Set the api_slug field if it is not synchronized to other fields
48+
api_slug = f'{self.content_type.service}.{self.codename}'
49+
if api_slug != self.api_slug:
50+
self.api_slug = api_slug
51+
if update_fields := kwargs.get('update_fields', []):
52+
update_fields.append('api_slug')
53+
return super().save(*args, **kwargs)

ansible_base/rbac/remote.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ def get_ct_from_type(cls):
3636
ct_model = apps.get_model('dab_rbac', 'DABContentType')
3737
return ct_model.objects.get_by_natural_key(cls._meta.service, cls._meta.app_label, cls._meta.model_name)
3838

39+
@classmethod
40+
def access_ids_qs(cls, actor, codename: str = 'view', content_types=None, cast_field=None):
41+
"""Returns a values_list type queryset of ids
42+
43+
Remote objects do not exist locally, so we can not get a queryset of them,
44+
but we can still do this, giving a queryset of ids.
45+
You could use the materialized list to filter API endpoints on the remote server?
46+
"""
47+
from .evaluations import remote_obj_id_qs
48+
49+
return remote_obj_id_qs(actor, remote_cls=cls, codename=codename, content_types=content_types, cast_field=cast_field)
50+
3951

4052
def get_remote_base_class() -> Type[RemoteObject]:
4153
"""Return the class which represents remote objects.
@@ -80,8 +92,7 @@ def get_resource_prefix(model: Union[Type[models.Model], models.Model, Type[Remo
8092
"""
8193
if isinstance(model, RemoteObject) or (inspect.isclass(model) and issubclass(model, RemoteObject)):
8294
# If it is a remote object, it was only ever created from this to begin with
83-
service, _, _ = model.type_data
84-
return service
95+
return model._meta.model_name
8596

8697
if registry := get_resource_registry():
8798
# duplicates logic in ansible_base/resource_registry/apps.py

ansible_base/rbac/validators.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
import re
23
from collections import defaultdict
34
from typing import Optional, Type, Union
@@ -27,8 +28,16 @@ def prnt_codenames(codename_set: set[str]) -> str:
2728
return ', '.join(codename_set)
2829

2930

30-
def codenames_for_cls(cls) -> list[str]:
31+
def codenames_for_remote_cls(cls: Union[Type[RemoteObject], RemoteObject]) -> list[str]:
32+
"""For remote objects, we have to use the database to get its known permissions"""
33+
ct = cls.get_ct_from_type()
34+
return [permission.codename for permission in ct.dab_permissions.all()]
35+
36+
37+
def codenames_for_cls(cls: Union[Model, Type[Model], Type[RemoteObject], RemoteObject]) -> list[str]:
3138
"Helper method that gives the Django permission codenames for a given class"
39+
if (inspect.isclass(cls) and issubclass(cls, RemoteObject)) or isinstance(cls, RemoteObject):
40+
return codenames_for_remote_cls(cls)
3241
return [t[0] for t in cls._meta.permissions] + [f'{act}_{cls._meta.model_name}' for act in cls._meta.default_permissions]
3342

3443

@@ -115,11 +124,8 @@ def check_view_permission_criteria(codename_set: set[str], permissions_by_model:
115124
because being able to change a thing without the ability to see it makes no sense.
116125
"""
117126
for cls, valid_model_permissions in permissions_by_model.items():
118-
# if issubclass(cls, RemoteObject):
119-
# from .models.content_type import DABContentType
120-
121-
# cls_ct = cls.get_ct_from_type()
122-
# if any(permission.codename.startswith('view') for permission in cls_ct.permissions.all()):
127+
# NOTE: there is some concern about using valid_model_permissions here, as opposed to all model permissions
128+
# however, no specific issue has yet been identified for this
123129
if any('view' in codename for codename in valid_model_permissions):
124130
model_permissions = set(valid_model_permissions) & codename_set
125131
local_codenames = {codename for codename in model_permissions if not is_add_perm(codename)}
@@ -193,7 +199,7 @@ def validate_permissions_for_model(permissions, content_type: Optional[Model], m
193199
raise ValidationError({'permissions', 'Local custom roles can only include view permission for shared models'})
194200

195201

196-
def validate_codename_for_model(codename: str, model: Union[Model, Type[Model]]) -> str:
202+
def validate_codename_for_model(codename: str, model: Union[Model, Type[Model], Type[RemoteObject], RemoteObject]) -> str:
197203
"""Shortcut method and validation to allow action name, codename, or app_name.codename
198204
199205
This institutes a shortcut for easier use of the evaluation methods

test_app/tests/rbac/remote/test_remote_models.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition
3+
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition, RoleUserAssignment
44
from ansible_base.rbac.remote import RemoteObject
55

66

@@ -11,4 +11,10 @@ def test_give_remote_permission(rando):
1111
DABPermission.objects.create(codename='foo_foo', content_type=foo_type)
1212
rd = RoleDefinition.objects.create_from_permissions(name='Foo fooers for the foos in foo service', permissions=['foo.foo_foo'], content_type=foo_type)
1313
a_foo = RemoteObject(content_type=foo_type, object_id=42)
14-
rd.give_permission(rando, a_foo)
14+
assignment = rd.give_permission(rando, a_foo)
15+
16+
assignment = RoleUserAssignment.objects.get(pk=assignment.pk)
17+
assert isinstance(assignment.content_object, RemoteObject)
18+
19+
# We can do evaluation querysets, but these can not return objects, just id values
20+
assert set(foo_type.model_class().access_ids_qs(actor=rando, codename='foo')) == {(int(assignment.object_id),)}

0 commit comments

Comments
 (0)