Skip to content

Commit 894af11

Browse files
[AAP-48392] Models and APIs for tracking remote permissions in DAB RBAC (#749)
## Description This modifies the RBAC app so that the models can store permissions for remote objects - objects that don't actually exist in the local server. To know which are which, a `service` field is added to our type-tracking model, which is also new as of this work. Importantly, permission _evaluations_ can be done for both local items and remote items. Why? Just as we have synchronization to a "resource server" via the resource registry app, this allows you to appoint a single service to be the gatekeeper for RBAC. This still requires synchronization, making it different from other approaches. Several new endpoints under `/service-index/` are introduced to help facilitate that synchronization. EDITing some snapshots of the progress state - As of opening, the core code is not finished being written, just want to get CI output continuously. - As of July 14 - relatively stable with current AWX, but integration work for rest of components is still WIP Fixes #80 Also request review from @dleehr @TheRealHaoLiu ## Type of Change <!-- Mandatory: Check one or more boxes that apply --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Test update - [ ] Refactoring (no functional changes) - [ ] Development environment change - [ ] Configuration change ## Self-Review Checklist <!-- These items help ensure quality - they complement our automated CI checks --> - [x] I have performed a self-review of my code - [x] I have added relevant comments to complex code sections - [x] I have updated documentation where needed - [x] I have considered the security impact of these changes - [x] I have considered performance implications - [x] I have thought about error handling and edge cases - [x] I have tested the changes in my local environment ## Testing Instructions <!-- Optional for test-only changes. Mandatory for all other changes --> <!-- Must be detailed enough for reviewers to reproduce --> ### Prerequisites <!-- List any specific setup required --> ### Steps to Test Some tests show how you can use this in isolation for types & permissions. That means, you can call `DABContentType.objects.load_remote_objects` to import some remote types, and then make roles using these types, and assign permissions. But that doesn't do much unless you set up a resource server and another service for it to track, and synchronize permissions between them. For this, you pretty much need aap-dev. Watching the demos also might be good to see test cases. Post-install those are mainly: - modify a role definition - assign a permission - repeat with the API from the resource server & the other server (AWX probably) ### Expected Results Role definitions & assignments synchronized using the endpoint system added here. ## Additional Context See backlinks for PRs that adopt this PR ### Required Actions <!-- Check if changes require work in other areas --> <!-- Remove section if no external actions needed --> - [ ] Requires documentation updates <!-- API docs, feature docs, deployment guides --> - [x] Requires downstream repository changes <!-- Specify repos: django-ansible-base, eda-server, etc. --> - [ ] Requires infrastructure/deployment changes <!-- CI/CD, installer updates, new services --> - [x] Requires coordination with other teams <!-- UI team, platform services, infrastructure --> - [ ] Blocked by PR/MR: #XXX <!-- Reference blocking PRs/MRs with brief context --> ### Screenshots/Logs <!-- Add if relevant to demonstrate the changes --> --------- Co-authored-by: Zack Kayyali <[email protected]>
1 parent 372a174 commit 894af11

File tree

79 files changed

+4195
-486
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+4195
-486
lines changed

ansible_base/authentication/utils/claims.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from django.conf import settings
99
from django.contrib.auth import get_user_model
1010
from django.contrib.auth.models import AbstractUser
11-
from django.contrib.contenttypes.models import ContentType
1211
from django.core.exceptions import ObjectDoesNotExist
1312
from django.db import IntegrityError, models
1413
from django.utils.timezone import now
@@ -21,6 +20,7 @@
2120
from ansible_base.lib.abstract_models import AbstractOrganization, AbstractTeam, CommonModel
2221
from ansible_base.lib.utils.auth import get_organization_model, get_team_model
2322
from ansible_base.lib.utils.string import is_empty
23+
from ansible_base.rbac.models import DABContentType
2424

2525
from .trigger_definition import TRIGGER_DEFINITION
2626

@@ -645,7 +645,7 @@ class RoleUserAssignmentsCache:
645645
def __init__(self):
646646
self.cache = {}
647647
# NOTE(cutwater): We may probably execute this query once and cache the query results.
648-
self.content_types = {content_type.model: content_type for content_type in ContentType.objects.get_for_models(Organization, Team).values()}
648+
self.content_types = {content_type.model: content_type for content_type in DABContentType.objects.get_for_models(Organization, Team).values()}
649649
self.role_definitions = {}
650650

651651
def items(self):

ansible_base/jwt_consumer/hub/auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ def process_permissions(self):
6565
for roledef_name, teams in [('Team Admin', admin_teams), ('Team Member', member_teams)]:
6666

6767
# the "shared" "non-local" definition ...
68-
roledef = RoleDefinition.objects.get(name=roledef_name)
68+
try:
69+
roledef = RoleDefinition.objects.get(name=roledef_name)
70+
except RoleDefinition.DoesNotExist:
71+
raise RoleDefinition.DoesNotExist(f'Expected JWT role {roledef_name} does not exist locally')
6972

7073
# pks for filtering ...
7174
team_pks = [team.pk for team in teams]

ansible_base/lib/abstract_models/common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,11 @@ def from_db(self, db, field_names, values):
167167
def get_summary_fields(self):
168168
response = {}
169169
for field in self._meta.fields:
170+
if field.name in self.ignore_relations:
171+
continue # This check is beneficial before getattr to prevent some error cases
170172
if isinstance(field, models.ForeignObject) and getattr(self, field.name):
171173
# ignore relations on inherited django models
172-
if field.name.endswith("_ptr") or (field.name in self.ignore_relations):
174+
if field.name.endswith("_ptr"):
173175
continue
174176
if hasattr(getattr(self, field.name), 'summary_fields'):
175177
response[field.name] = getattr(self, field.name).summary_fields()

ansible_base/lib/dynamic_config/dynamic_urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
installed_apps = getattr(settings, 'INSTALLED_APPS', [])
1414
for app in installed_apps:
1515
if app.startswith('ansible_base.'):
16+
if app in getattr(settings, 'ANSIBLE_BASE_APPS_EXCLUDE_VIEW_LIST', []):
17+
continue
1618
if not importlib.util.find_spec(f'{app}.urls'):
1719
logger.debug(f'Module {app} does not specify urls.py')
1820
continue

ansible_base/lib/dynamic_config/settings_logic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def get_mergeable_dab_settings(settings: dict) -> dict: # NOSONAR
108108
'user_ansible_id',
109109
'team_ansible_id',
110110
'object_ansible_id',
111+
'assignment', # for RoleAssignmentFilterBackend, assignment filtering
111112
)
112113

113114
# SPECTACULAR SETTINGS

ansible_base/rbac/api/queries.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Union
2+
3+
from django.db.models import Model
4+
5+
from ..models import DABContentType, get_evaluation_model
6+
from ..remote import RemoteObject
7+
8+
9+
def assignment_qs_user_to_obj(actor: Model, obj: Union[Model, RemoteObject]):
10+
"""Queryset of assignments (team or user) that grants the actor any form of permission to obj"""
11+
evaluation_cls = get_evaluation_model(obj)
12+
ct = DABContentType.objects.get_for_model(obj)
13+
reverse_name = evaluation_cls._meta.get_field('role').remote_field.name
14+
15+
# All relevant assignments for the object
16+
obj_eval_qs = evaluation_cls.objects.filter(object_id=obj.pk, content_type_id=ct.id)
17+
obj_assignment_qs = actor.role_assignments.filter(**{f'object_role__{reverse_name}__in': obj_eval_qs})
18+
19+
global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions__content_type=ct)
20+
21+
return (global_assignment_qs | obj_assignment_qs).distinct()
22+
23+
24+
def assignment_qs_user_to_obj_perm(actor: Model, obj: Union[Model, RemoteObject], permission: Model):
25+
"""Queryset of assignments that grants this specific permission to this specific object"""
26+
evaluation_cls = get_evaluation_model(obj)
27+
ct = DABContentType.objects.get_for_model(obj)
28+
reverse_name = evaluation_cls._meta.get_field('role').remote_field.name
29+
30+
obj_eval_qs = evaluation_cls.objects.filter(codename=permission.codename, object_id=obj.pk, content_type_id=ct.id)
31+
obj_assignment_qs = actor.role_assignments.filter(**{f'object_role__{reverse_name}__in': obj_eval_qs})
32+
33+
global_assignment_qs = actor.role_assignments.filter(content_type=None, role_definition__permissions=permission)
34+
35+
return (global_assignment_qs | obj_assignment_qs).distinct()

ansible_base/rbac/api/serializers.py

Lines changed: 123 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -2,135 +2,45 @@
22
from django.core.exceptions import ObjectDoesNotExist
33
from django.db import transaction
44
from django.db.utils import IntegrityError
5-
from django.utils.functional import cached_property
65
from django.utils.translation import gettext_lazy as _
76
from rest_framework import serializers
87
from rest_framework.exceptions import PermissionDenied
9-
from rest_framework.fields import flatten_choices_dict, to_choices_dict
108
from rest_framework.serializers import ValidationError
119

1210
from ansible_base.lib.abstract_models.common import get_url_for_object
13-
from ansible_base.lib.serializers.common import CommonModelSerializer, ImmutableCommonModelSerializer
11+
from ansible_base.lib.serializers.common import AbstractCommonModelSerializer, CommonModelSerializer, ImmutableCommonModelSerializer
12+
from ansible_base.lib.utils.auth import get_team_model
13+
from ansible_base.lib.utils.response import get_relative_url
1414
from ansible_base.rbac.models import RoleDefinition, RoleTeamAssignment, RoleUserAssignment
1515
from ansible_base.rbac.permission_registry import permission_registry # careful for circular imports
1616
from ansible_base.rbac.policies import check_content_obj_permission, visible_users
1717
from ansible_base.rbac.validators import check_locally_managed, validate_permissions_for_model
1818

19-
20-
class ChoiceLikeMixin(serializers.ChoiceField):
21-
"""
22-
This uses a ForeignKey to populate the choices of a choice field.
23-
This also manages some string manipulation, right now, adding the local service name.
24-
"""
25-
26-
default_error_messages = serializers.PrimaryKeyRelatedField.default_error_messages
27-
28-
def get_dynamic_choices(self):
29-
raise NotImplementedError
30-
31-
def get_dynamic_object(self, data):
32-
raise NotImplementedError
33-
34-
def to_representation(self, value):
35-
raise NotImplementedError
36-
37-
def __init__(self, **kwargs):
38-
# Workaround so that the parent class does not resolve the choices right away
39-
self.html_cutoff = kwargs.pop('html_cutoff', self.html_cutoff)
40-
self.html_cutoff_text = kwargs.pop('html_cutoff_text', self.html_cutoff_text)
41-
42-
self.allow_blank = kwargs.pop('allow_blank', False)
43-
super(serializers.ChoiceField, self).__init__(**kwargs)
44-
45-
def _initialize_choices(self):
46-
choices = self.get_dynamic_choices()
47-
self._grouped_choices = to_choices_dict(choices)
48-
self._choices = flatten_choices_dict(self._grouped_choices)
49-
self.choice_strings_to_values = {str(k): k for k in self._choices}
50-
51-
@cached_property
52-
def grouped_choices(self):
53-
self._initialize_choices()
54-
return self._grouped_choices
55-
56-
@cached_property
57-
def choices(self):
58-
self._initialize_choices()
59-
return self._choices
60-
61-
def to_internal_value(self, data):
62-
try:
63-
return self.get_dynamic_object(data)
64-
except ObjectDoesNotExist:
65-
self.fail('does_not_exist', pk_value=data)
66-
except (TypeError, ValueError):
67-
self.fail('incorrect_type', data_type=type(data).__name__)
68-
69-
70-
class ContentTypeField(ChoiceLikeMixin):
71-
def __init__(self, **kwargs):
72-
kwargs['help_text'] = _('The type of resource this applies to.')
73-
super().__init__(**kwargs)
74-
75-
def get_resource_type_name(self, cls) -> str:
76-
return f"{permission_registry.get_resource_prefix(cls)}.{cls._meta.model_name}"
77-
78-
def get_dynamic_choices(self):
79-
return list(sorted((self.get_resource_type_name(cls), cls._meta.verbose_name.title()) for cls in permission_registry.all_registered_models))
80-
81-
def get_dynamic_object(self, data):
82-
model = data.rsplit('.')[-1]
83-
cls = permission_registry.get_model_by_name(model)
84-
if cls is None:
85-
return permission_registry.content_type_model.objects.none().get() # raises correct DoesNotExist
86-
return permission_registry.content_type_model.objects.get_for_model(cls)
87-
88-
def to_representation(self, value):
89-
if isinstance(value, str):
90-
return value # slight hack to work to AWX schema tests
91-
return self.get_resource_type_name(value.model_class())
92-
93-
94-
class PermissionField(ChoiceLikeMixin):
95-
@property
96-
def service_prefix(self):
97-
if registry := permission_registry.get_resource_registry():
98-
return registry.api_config.service_type
99-
return 'local'
100-
101-
def get_dynamic_choices(self):
102-
perms = []
103-
for cls in permission_registry.all_registered_models:
104-
cls_name = cls._meta.model_name
105-
for action in cls._meta.default_permissions:
106-
perms.append(f'{permission_registry.get_resource_prefix(cls)}.{action}_{cls_name}')
107-
for perm_name, description in cls._meta.permissions:
108-
perms.append(f'{permission_registry.get_resource_prefix(cls)}.{perm_name}')
109-
return list(sorted(perms))
110-
111-
def get_dynamic_object(self, data):
112-
codename = data.rsplit('.')[-1]
113-
return permission_registry.permission_qs.get(codename=codename)
114-
115-
def to_representation(self, value):
116-
if isinstance(value, str):
117-
return value # slight hack to work to AWX schema tests
118-
ct = permission_registry.content_type_model.objects.get_for_id(value.content_type_id) # optimization
119-
return f'{permission_registry.get_resource_prefix(ct.model_class())}.{value.codename}'
120-
121-
122-
class ManyRelatedListField(serializers.ListField):
123-
def to_representation(self, data):
124-
"Adds the .all() to treat the value as a queryset"
125-
return [self.child.to_representation(item) if item is not None else None for item in data.all()]
19+
from ..models import DABContentType, DABPermission
20+
from ..remote import RemoteObject
21+
from .queries import assignment_qs_user_to_obj, assignment_qs_user_to_obj_perm
12622

12723

12824
class RoleDefinitionSerializer(CommonModelSerializer):
129-
# Relational versions - we may switch to these if custom permission and type models are exposed but out of scope here
130-
# permissions = serializers.SlugRelatedField(many=True, slug_field='codename', queryset=DABPermission.objects.all())
131-
# content_type = ContentTypeField(slug_field='model', queryset=permission_registry.content_type_model.objects.all(), allow_null=True, default=None)
132-
permissions = ManyRelatedListField(child=PermissionField())
133-
content_type = ContentTypeField(allow_null=True, default=None)
25+
permissions = serializers.SlugRelatedField(
26+
slug_field='api_slug',
27+
queryset=DABPermission.objects.all(),
28+
many=True,
29+
error_messages={
30+
'does_not_exist': "Cannot use permission with api_slug '{value}', object does not exist",
31+
'invalid': "Each content type must be a valid slug string",
32+
},
33+
)
34+
content_type = serializers.SlugRelatedField(
35+
slug_field='api_slug',
36+
queryset=DABContentType.objects.all(),
37+
allow_null=True, # for global roles
38+
default=None,
39+
error_messages={
40+
'does_not_exist': "Cannot use type with api_slug '{value}', object does not exist",
41+
'invalid': "Each content type must be a valid slug string",
42+
},
43+
)
13444

13545
class Meta:
13646
model = RoleDefinition
@@ -145,7 +55,7 @@ def validate(self, validated_data):
14555
permissions = list(self.instance.permissions.all())
14656
if 'content_type' in validated_data:
14757
content_type = validated_data['content_type']
148-
else:
58+
elif self.instance:
14959
content_type = self.instance.content_type
15060
validate_permissions_for_model(permissions, content_type)
15161
if getattr(self, 'instance', None):
@@ -154,11 +64,11 @@ def validate(self, validated_data):
15464

15565

15666
class RoleDefinitionDetailSerializer(RoleDefinitionSerializer):
157-
content_type = ContentTypeField(read_only=True)
67+
content_type = serializers.SlugRelatedField(slug_field='api_slug', read_only=True)
15868

15969

16070
class BaseAssignmentSerializer(CommonModelSerializer):
161-
content_type = ContentTypeField(read_only=True, allow_null=True)
71+
content_type = serializers.SlugRelatedField(slug_field='api_slug', read_only=True)
16272
object_ansible_id = serializers.UUIDField(
16373
required=False,
16474
help_text=_('The resource id of the object this role applies to. An alternative to the object_id field.'),
@@ -222,6 +132,8 @@ def get_object_from_data(self, validated_data, role_definition, requesting_user)
222132
if not role_definition.content_type:
223133
raise ValidationError({'object_id': _('System role does not allow for object assignment')})
224134
model = role_definition.content_type.model_class()
135+
if issubclass(model, RemoteObject):
136+
return model(content_type=role_definition.content_type, object_id=validated_data['object_id'])
225137
try:
226138
obj = serializers.PrimaryKeyRelatedField(queryset=model.access_qs(requesting_user)).to_internal_value(validated_data['object_id'])
227139
except ValidationError as exc:
@@ -336,3 +248,96 @@ def get_actor_queryset(self, requesting_user):
336248

337249
class RoleMetadataSerializer(serializers.Serializer):
338250
allowed_permissions = serializers.DictField(help_text=_('A List of permissions allowed for a role definition, given its content type.'))
251+
252+
253+
class AccessListMixin:
254+
255+
def _get_related(self, obj) -> dict[str, str]:
256+
if obj is None:
257+
return {}
258+
related_fields = {}
259+
actor_cls = self.Meta.model
260+
related_fields['details'] = get_relative_url(
261+
f'role-{actor_cls._meta.model_name}-access-assignments',
262+
kwargs={'model_name': self.context.get("content_type").api_slug, 'pk': self.context.get("related_object").pk, 'actor_pk': obj.pk},
263+
)
264+
return related_fields
265+
266+
@staticmethod
267+
def summarize_role_definition(role_definition):
268+
return {"name": role_definition.name, "url": get_url_for_object(role_definition)}
269+
270+
@staticmethod
271+
def summarize_assignment_list(assignment_qs, obj_ct):
272+
assignment_list = []
273+
team_ct = DABContentType.objects.get_for_model(get_team_model())
274+
for assignment in assignment_qs.distinct():
275+
if assignment.content_type_id is None:
276+
perm_type = "global"
277+
elif assignment.content_type_id == team_ct.pk:
278+
perm_type = "team"
279+
elif assignment.content_type_id == obj_ct.pk:
280+
perm_type = "direct"
281+
else:
282+
perm_type = "indirect"
283+
assignment_list.append({"type": perm_type, "role_definition": AccessListMixin.summarize_role_definition(assignment.role_definition)})
284+
285+
return assignment_list
286+
287+
def get_object_role_assignments(self, actor):
288+
obj = self.context.get("related_object")
289+
permission = self.context.get("permission")
290+
ct = self.context.get("content_type")
291+
292+
if permission:
293+
assignment_qs = assignment_qs_user_to_obj_perm(actor, obj, permission)
294+
else:
295+
assignment_qs = assignment_qs_user_to_obj(actor, obj)
296+
297+
return self.summarize_assignment_list(assignment_qs, ct)
298+
299+
def get_url(self, obj) -> str:
300+
return get_url_for_object(obj)
301+
302+
303+
class UserAccessListMixin(AccessListMixin, serializers.ModelSerializer):
304+
"controller uses auth.User model so this needs to be as compatible as possible, thus ModelSerializer"
305+
306+
object_role_assignments = serializers.SerializerMethodField()
307+
url = serializers.SerializerMethodField()
308+
related = serializers.SerializerMethodField('_get_related')
309+
_expected_fields = ['id', 'url', 'related', 'username', 'is_superuser', 'object_role_assignments']
310+
311+
312+
class TeamAccessListMixin(AccessListMixin, AbstractCommonModelSerializer):
313+
object_role_assignments = serializers.SerializerMethodField()
314+
url = serializers.SerializerMethodField()
315+
related = serializers.SerializerMethodField('_get_related')
316+
_expected_fields = ['id', 'url', 'related', 'name', 'organization', 'object_role_assignments']
317+
318+
319+
class UserAccessAssignmentSerializer(RoleUserAssignmentSerializer):
320+
intermediary_roles = serializers.SerializerMethodField()
321+
322+
class Meta(RoleUserAssignmentSerializer.Meta):
323+
fields = RoleUserAssignmentSerializer.Meta.fields + ['intermediary_roles']
324+
325+
def get_intermediary_roles(self, assignment):
326+
team_ct = DABContentType.objects.get_for_model(get_team_model())
327+
328+
permission = self.context.get("permission")
329+
if assignment.content_type != team_ct:
330+
return []
331+
team = assignment.content_object
332+
obj = self.context.get("related_object")
333+
334+
if permission:
335+
assignment_qs = assignment_qs_user_to_obj_perm(team, obj, permission)
336+
else:
337+
assignment_qs = assignment_qs_user_to_obj(team, obj)
338+
339+
return AccessListMixin.summarize_assignment_list(assignment_qs, self.context.get("content_type"))
340+
341+
342+
class TeamAccessAssignmentSerializer(RoleTeamAssignmentSerializer):
343+
pass

0 commit comments

Comments
 (0)