Skip to content

Commit 26d95eb

Browse files
committed
Finishing work for assignment sync endpoints
1 parent 09cfa50 commit 26d95eb

File tree

5 files changed

+173
-20
lines changed

5 files changed

+173
-20
lines changed

ansible_base/rbac/management/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from collections import defaultdict
23

34
from django.apps import apps as global_apps
45
from django.db import DEFAULT_DB_ALIAS, connection, router
@@ -94,8 +95,14 @@ def sync_DAB_permissions(verbosity=2, using=DEFAULT_DB_ALIAS, apps=global_apps):
9495
perms.append(permission)
9596

9697
Permission.objects.using(using).bulk_create(perms)
98+
99+
# This is all just for nice logging
100+
perms_by_model_name = defaultdict(list)
97101
for perm in perms:
98-
logger.debug("Adding permission '%s'" % perm)
102+
perms_by_model_name[perm.content_type.model].append(perm.codename)
103+
for model_name, codenames in perms_by_model_name.items():
104+
codename_prnt = ', '.join(codenames)
105+
logger.debug(f"Added DAB permissions for model {model_name}: {codename_prnt}")
99106

100107
# Reset the sequence to avoid PK collision later
101108
if connection.vendor == 'postgresql':

ansible_base/rbac/service_api/serializers.py

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from crum import impersonate
12
from django.apps import apps
3+
from django.db import transaction
4+
from django.utils.translation import gettext_lazy as _
25
from rest_framework import serializers
36

47
from ..models import DABContentType, DABPermission, RoleDefinition, RoleTeamAssignment, RoleUserAssignment
@@ -35,33 +38,96 @@ def to_internal_value(self, data):
3538
return resource.content_object
3639

3740

41+
class ObjectIDAnsibleIDField(serializers.Field):
42+
"This is an ansible_id field intended to be used with source pointing to object_id, so, does conversion"
43+
44+
def to_representation(self, value):
45+
"The source for this field is object_id, which is ignored, use content_object instead"
46+
assignment = self.parent.instance
47+
content_object = assignment.content_object
48+
if isinstance(content_object, RemoteObject):
49+
return None
50+
if hasattr(content_object, 'resource'):
51+
return str(content_object.resource.ansible_id)
52+
return None
53+
54+
def to_internal_value(self, value):
55+
"Targeting object_id, this converts ansible_id into object_id"
56+
resource_cls = apps.get_model('dab_resource_registry', 'Resource')
57+
resource = resource_cls.objects.get(ansible_id=value)
58+
return resource.object_id
59+
60+
3861
assignment_common_fields = ('created', 'created_by_ansible_id', 'object_id', 'object_ansible_id', 'content_type', 'role_definition')
3962

4063

4164
class BaseAssignmentSerializer(serializers.ModelSerializer):
4265
content_type = serializers.SlugRelatedField(read_only=True, slug_field='api_slug')
4366
role_definition = serializers.SlugRelatedField(slug_field='name', queryset=RoleDefinition.objects.all())
4467
created_by_ansible_id = ActorAnsibleIDField(source='created_by', required=False)
45-
object_ansible_id = serializers.SerializerMethodField()
68+
object_ansible_id = ObjectIDAnsibleIDField(source='object_id', required=False)
4669
# TODO: use the from_service to control what we sync back to
4770
from_service = serializers.CharField(write_only=True)
4871

4972
def get_created_by_ansible_id(self, obj):
5073
return str(obj.created_by.resource.ansible_id)
5174

52-
def get_object_ansible_id(self, obj):
53-
content_object = obj.content_object
54-
if isinstance(content_object, RemoteObject):
55-
return None
56-
if hasattr(content_object, 'resource'):
57-
return str(content_object.resource.ansible_id)
58-
return None
75+
def validate(self, attrs):
76+
"""The object_id vs ansible_id is the only dual-write case, where we have to accept either
77+
78+
So this does the mutual validation to assure we have sufficient data.
79+
"""
80+
has_oid = 'object_id' in self.initial_data
81+
has_oaid = 'object_ansible_id' in self.initial_data
82+
83+
if not self.partial and not has_oid and not has_oaid:
84+
raise serializers.ValidationError("You must provide either 'object_id' or 'object_ansible_id'.")
85+
86+
# NOTE: right now not enforcing the case you provide both, could check for consistency later
87+
88+
return attrs
5989

6090
def find_existing_assignment(self, queryset):
6191
actor = self.validated_data[self.actor_field]
62-
object_id = self.validated_data['object_id']
6392
role_definition = self.validated_data['role_definition']
64-
return queryset.filter(object_id=object_id, role_definition=role_definition, **{self.actor_field: actor}).first()
93+
object_id = self.validated_data['object_id']
94+
filter_kwargs = {self.actor_field: actor}
95+
return queryset.filter(role_definition=role_definition, object_id=object_id, **filter_kwargs).first()
96+
97+
def create(self, validated_data):
98+
rd = validated_data['role_definition']
99+
actor = validated_data[self.actor_field]
100+
101+
as_user = None
102+
if 'created_by' in validated_data:
103+
as_user = validated_data['created_by']
104+
105+
# Unlike the public view, the action is attributed to the specified user in data
106+
with impersonate(as_user):
107+
108+
object_id = validated_data['object_id']
109+
obj = None
110+
if object_id:
111+
model = rd.content_type.model_class()
112+
try:
113+
obj = model.objects.get(pk=object_id)
114+
except model.DoesNotExist as exc:
115+
raise serializers.ValidationError({'object_id': str(exc)})
116+
117+
# Validators not ran, because this should be an internal action
118+
119+
if rd.content_type:
120+
# Object role assignment
121+
if not obj:
122+
raise serializers.ValidationError({'object_id': _('Object must be specified for this role assignment')})
123+
124+
with transaction.atomic():
125+
assignment = rd.give_permission(actor, obj)
126+
else:
127+
with transaction.atomic():
128+
assignment = rd.give_global_permission(actor)
129+
130+
return assignment
65131

66132

67133
class RoleUserAssignmentSerializer(BaseAssignmentSerializer):

ansible_base/rbac/service_api/views.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.db import transaction
12
from rest_framework import status
23
from rest_framework.decorators import action
34
from rest_framework.response import Response
@@ -59,10 +60,8 @@ def assign(self, request):
5960

6061
existing = serializer.find_existing_assignment(self.get_queryset())
6162
if existing:
62-
return Response(
63-
{"detail": "This assignment already exists."},
64-
status=status.HTTP_409_CONFLICT,
65-
)
63+
output_serializer = self.get_serializer(existing)
64+
return Response(output_serializer.data, status=status.HTTP_200_OK)
6665

6766
instance = serializer.save()
6867
output_serializer = self.get_serializer(instance)
@@ -75,15 +74,21 @@ def unassign(self, request):
7574

7675
existing = serializer.find_existing_assignment(self.get_queryset())
7776
if not existing:
78-
return Response(
79-
{"detail": "No such assignment exists."},
80-
status=status.HTTP_409_CONFLICT,
81-
)
77+
output_serializer = self.get_serializer(existing)
78+
return Response(output_serializer.data, status=status.HTTP_200_OK)
8279

8380
# Use standard DRF delete logic
8481
self.perform_destroy(existing)
8582
return Response(status=status.HTTP_204_NO_CONTENT)
8683

84+
def perform_destroy(self, instance):
85+
if instance.content_type_id:
86+
with transaction.atomic():
87+
instance.role_definition.remove_permission(instance.actor, instance.content_object)
88+
else:
89+
with transaction.atomic():
90+
instance.role_definition.remove_global_permission(instance.actor)
91+
8792

8893
class ServiceRoleTeamAssignmentViewSet(
8994
AnsibleBaseDjangoAppApiView,

ansible_base/resource_registry/rest_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import requests
77
import urllib3
8+
from django.apps import apps
89

910
from ansible_base.resource_registry.resource_server import get_resource_server_config, get_service_token
1011

@@ -166,3 +167,43 @@ def list_role_types(self, filters: Optional[dict] = None):
166167

167168
def list_role_permissions(self, filters: Optional[dict] = None):
168169
return self._make_request("get", "role-permissions/", params=filters)
170+
171+
def sync_assignment(self, assignment):
172+
from ansible_base.rbac.service_api.serializers import RoleTeamAssignmentSerializer, RoleUserAssignmentSerializer
173+
174+
if assignment._meta.model_name == 'roleuserassignment':
175+
serializer = RoleUserAssignmentSerializer(assignment)
176+
else:
177+
serializer = RoleTeamAssignmentSerializer(assignment)
178+
179+
return self._sync_assignment(serializer.data)
180+
181+
def sync_unassignment(self, role_definition, actor, content_object):
182+
data = {'role_definition': role_definition.name}
183+
data[f'{actor._meta.model_name}_ansible_id'] = str(actor.resource.ansible_id)
184+
185+
if content_object is None:
186+
data['object_id'] = None
187+
else:
188+
ct_cls = apps.get_model('dab_rbac', 'DABContentType')
189+
ct = ct_cls.objects.get_for_model(content_object)
190+
if ct.service == 'shared':
191+
data['object_ansible_id'] = str(content_object.resource.ansible_id)
192+
else:
193+
data['object_id'] = content_object.object_id
194+
195+
return self._sync_assignment(data, giving=False)
196+
197+
def _sync_assignment(self, data, giving=True):
198+
if giving:
199+
sub_url = 'assign'
200+
else:
201+
sub_url = 'unassign'
202+
203+
actor_type = 'user'
204+
if 'team' in data:
205+
actor_type = 'team'
206+
207+
url = f'role-{actor_type}-assignments/{sub_url}/'
208+
209+
return self._make_request(method="post", path=url, data=data)

test_app/tests/resource_registry/test_resources_api_rest_client.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,40 @@ def test_list_role_permissions_all_pages(resource_client):
183183
assert resp.json()["count"] > 25
184184

185185

186+
def _assert_assignment_matches_data(assignment, data, organization, user):
187+
assert 'created' in data, data
188+
# assert DateTimeField().to_representation(assignment.created) == data['created'] # TODO
189+
assert str(assignment.created_by.resource.ansible_id) == data['created_by_ansible_id']
190+
assert assignment.object_id == organization.id
191+
assert str(assignment.object_id) == str(data['object_id'])
192+
assert str(organization.resource.ansible_id) == data['object_ansible_id']
193+
assert 'shared.organization' == data['content_type']
194+
assert 'Organization Admin' == data['role_definition']
195+
assert str(user.resource.ansible_id) == data['user_ansible_id']
196+
197+
198+
@pytest.mark.django_db
199+
def test_sync_assignment(resource_client, org_admin_rd, user, organization):
200+
assignment = org_admin_rd.give_permission(user, organization)
201+
resp = resource_client.sync_assignment(assignment)
202+
203+
assert resp.status_code == 200, resp.text
204+
205+
data = resp.json()
206+
# Existing assignment should be this current assignment
207+
_assert_assignment_matches_data(assignment, data, organization, user)
208+
209+
org_admin_rd.remove_permission(user, organization)
210+
211+
resp = resource_client.sync_assignment(assignment) # assignment not actually here locally
212+
213+
assert resp.status_code == 201, resp.text # created
214+
215+
data = resp.json()
216+
# All the data, on the remote system, should match our original assignment
217+
_assert_assignment_matches_data(assignment, data, organization, user)
218+
219+
186220
@pytest.mark.django_db
187221
def test_get_resource_404(resource_client):
188222
resource_client.raise_if_bad_request = True
@@ -214,7 +248,7 @@ def test_additional_data_read(resource_client, django_user_model, github_authent
214248
@pytest.mark.django_db
215249
@pytest.mark.parametrize('partial', [True, False])
216250
def test_additional_data_write(resource_client, partial):
217-
"Will remove a permission from a role definition using the additional_data field."
251+
"Will remove a permission from a role definition."
218252
rd = RoleDefinition.objects.create_from_permissions(
219253
permissions=['aap.change_inventory', 'aap.view_inventory'],
220254
name='change-inv-for-now',

0 commit comments

Comments
 (0)