Skip to content

Commit b4e8524

Browse files
committed
Allow refresh of managed roles, use api_slugs
1 parent 8d291cc commit b4e8524

File tree

7 files changed

+160
-27
lines changed

7 files changed

+160
-27
lines changed

ansible_base/rbac/managed.py

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,51 @@ def get_content_type(self, apps):
4646
content_type_cls = apps.get_model('contenttypes', 'ContentType')
4747
return content_type_cls.objects.get_for_model(model)
4848

49+
def refresh_permissions(self, rd, apps):
50+
"""Make role permissions equal what the managed definition specifies"""
51+
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
52+
53+
# Desired permission codenames from managed source
54+
desired_codenames = set(self.get_permissions(apps))
55+
56+
# Resolve to actual permission objects or error
57+
desired_permissions = []
58+
for codename in desired_codenames:
59+
try:
60+
if '.' in codename:
61+
perm = permission_cls.objects.get(api_slug=codename)
62+
else:
63+
perm = permission_cls.objects.get(codename=codename)
64+
desired_permissions.append(perm)
65+
except permission_cls.DoesNotExist:
66+
db_codenames = list(permission_cls.objects.values_list('codename', flat=True))
67+
raise permission_cls.DoesNotExist(
68+
f'Permission codename "{codename}" does not exist.\n'
69+
f'Managed role: {self}\nExpected: {sorted(desired_codenames)}\n'
70+
f'Available in DB: {sorted(db_codenames)}'
71+
)
72+
73+
desired_set = set(desired_permissions)
74+
current_set = set(rd.permissions.all())
75+
76+
to_add = desired_set - current_set
77+
to_remove = current_set - desired_set
78+
79+
if not to_add and not to_remove:
80+
logger.info(f'No permission changes needed for role "{self.name}"')
81+
else:
82+
if to_add:
83+
rd.permissions.add(*to_add)
84+
added_codenames = sorted(p.codename for p in to_add)
85+
logger.info(f'Added permissions to role "{self.name}": {added_codenames}')
86+
87+
if to_remove:
88+
rd.permissions.remove(*to_remove)
89+
removed_codenames = sorted(p.codename for p in to_remove)
90+
logger.info(f'Removed permissions from role "{self.name}": {removed_codenames}')
91+
92+
logger.debug(f'Final permissions for role "{self.name}": ' f'{sorted(p.codename for p in rd.permissions.all())}')
93+
4994
def get_or_create(self, apps):
5095
"Create from a list of text-type permissions and do validation"
5196
role_definition_cls = apps.get_model('dab_rbac', 'RoleDefinition')
@@ -57,36 +102,33 @@ def get_or_create(self, apps):
57102
rd, created = role_definition_cls.objects.get_or_create(name=self.name, defaults=defaults)
58103

59104
if created:
60-
permissions = self.get_permissions(apps)
61-
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
62-
perm_list = []
63-
for str_perm in permissions:
64-
try:
65-
perm_list.append(permission_cls.objects.get(codename=str_perm))
66-
except permission_cls.DoesNotExist:
67-
# Better error handling for debugging
68-
db_codenames = list(permission_cls.objects.values_list('codename', flat=True))
69-
raise permission_cls.DoesNotExist(
70-
f'Permission codename {str_perm} does not exist. Manged role {self}\n expected: {permissions}\n Database permissions: {db_codenames}'
71-
)
72-
rd.permissions.add(*perm_list)
73-
logger.info(f'Created {self.shortname} managed role definition, name={self.name}')
105+
self.refresh_permissions(rd, apps=apps)
74106
logger.debug(f'Data of {self.name} role definition: {defaults}')
75-
logger.debug(f'Permissions of {self.name} role definition: {permissions}')
76107
return rd, created
77108

78-
def allowed_permissions(self, model: Optional[Type[Model]]) -> set[str]:
79-
from ansible_base.rbac.validators import combine_values, permissions_allowed_for_role
109+
def allowed_permissions_by_model(self, model: Optional[Type[Model]]) -> dict[Type, list[str]]:
110+
from ansible_base.rbac.validators import permissions_allowed_for_role
111+
112+
return permissions_allowed_for_role(model)
113+
114+
def allowed_permissions_slug_list(self, model: Optional[Type[Model]]) -> set[str]:
115+
"Returns all possible permissions for model in terms format of awx.change_inventory"
116+
from ansible_base.rbac.remote import get_resource_prefix
80117

81-
return combine_values(permissions_allowed_for_role(model))
118+
slug_list = set()
119+
for child_model, child_codenames in self.allowed_permissions_by_model(model).items():
120+
prefix = get_resource_prefix(child_model)
121+
for codename in child_codenames:
122+
slug_list.add(f'{prefix}.{codename}')
123+
return slug_list
82124

83125

84126
class ManagedAdminBase(ManagedRoleConstructor):
85127
description = gettext_noop("Has all permissions to a single {model_name_verbose}")
86128

87129
def get_permissions(self, apps) -> set[str]:
88130
"""All permissions possible for the associated model"""
89-
return self.allowed_permissions(self.get_model(apps))
131+
return self.allowed_permissions_slug_list(self.get_model(apps))
90132

91133

92134
class ManagedActionBase(ManagedRoleConstructor):
@@ -108,7 +150,7 @@ class ManagedReadOnlyBase(ManagedRoleConstructor):
108150
description = gettext_noop("Has all viewing related permissions that can be delegated via {model_name_verbose}")
109151

110152
def get_permissions(self, apps) -> set[str]:
111-
return {codename for codename in self.allowed_permissions(self.get_model(apps)) if codename.startswith('view')}
153+
return {api_slug for api_slug in self.allowed_permissions_slug_list(self.get_model(apps)) if '.view' in api_slug}
112154

113155

114156
class OrganizationMixin:

ansible_base/rbac/models/content_type.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def get_for_id(self, id: int) -> django_models.Model:
174174
self._add_to_cache(self.db, ct)
175175
return ct
176176

177-
def load_remote_types(self, remote_data: list[dict]):
177+
def load_remote_objects(self, remote_data: list[dict]):
178178
parent_mapping: dict[django_models.Model, str] = {}
179179
for remote_type in remote_data:
180180
service = remote_type.pop('service')

ansible_base/rbac/models/permission.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1+
from django.apps import apps
12
from django.db import models
23
from django.utils.translation import gettext_lazy as _
34

45
from .content_type import DABContentType
56

67

78
class DABPermissionManager(models.Manager):
8-
def load_remote_types(self, remote_data: list[dict]):
9+
def load_remote_objects(self, remote_data: list[dict], update_managed=False):
910
for remote_type in remote_data:
1011
codename = remote_type.pop('codename')
1112
ct_slug = remote_type.pop('content_type')
1213
ct = DABContentType.objects.get(api_slug=ct_slug)
1314
ct, _ = self.get_or_create(codename=codename, content_type=ct, defaults=remote_type)
1415

16+
if update_managed:
17+
from ansible_base.rbac import permission_registry
18+
19+
permission_registry.create_managed_roles(apps, update_perms=True)
20+
1521

1622
class DABPermission(models.Model):
1723
"This is a minimal copy of auth.Permission for internal use"

ansible_base/rbac/permission_registry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def register_managed_role_constructors(self) -> None:
108108
for shortname, constructor in managed_defs.items():
109109
self.register_managed_role_constructor(shortname, constructor)
110110

111-
def create_managed_roles(self, apps) -> list[tuple[Model, bool]]:
111+
def create_managed_roles(self, apps, update_perms=False) -> list[tuple[Model, bool]]:
112112
"""Safe-ish method to create managed roles inside of a migration
113113
114114
Returns a list with all the managed RoleDefinition objects and whether they were created
@@ -118,6 +118,8 @@ def create_managed_roles(self, apps) -> list[tuple[Model, bool]]:
118118
ret = []
119119
for managed_role in self._managed_roles.values():
120120
rd, created = managed_role.get_or_create(apps)
121+
if update_perms and (not created):
122+
managed_role.refresh_permissions(rd, apps)
121123
ret.append((rd, created))
122124
return ret
123125

ansible_base/rbac/remote.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def get_resource_prefix(model: Union[Type[models.Model], models.Model, Type[Remo
163163
"""
164164
if isinstance(model, RemoteObject) or (inspect.isclass(model) and issubclass(model, RemoteObject)):
165165
# If it is a remote object, it was only ever created from this to begin with
166-
return model._meta.model_name
166+
return model._meta.service
167167

168168
if registry := get_resource_registry():
169169
# duplicates logic in ansible_base/resource_registry/apps.py
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import pytest
2+
from django.apps import apps
3+
from django.test import override_settings
4+
5+
from ansible_base.rbac import permission_registry
6+
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition
7+
from ansible_base.rbac.remote import get_resource_prefix
8+
9+
10+
@pytest.mark.django_db
11+
@override_settings(ANSIBLE_BASE_MANAGED_ROLE_REGISTRY={'org_admin': {}})
12+
def test_remote_permissions_on_create(foo_permission):
13+
rd = RoleDefinition.objects.managed.org_admin
14+
assert foo_permission in rd.permissions.all()
15+
16+
17+
@pytest.mark.django_db
18+
def test_remote_permission_refresh(foo_type):
19+
rd = RoleDefinition.objects.managed.org_admin
20+
21+
foo_permission = DABPermission.objects.create(codename='foo_foo', content_type=foo_type)
22+
23+
# the foo_permission was created after the managed role
24+
# so the role is not explicitly created
25+
assert foo_permission not in rd.permissions.all()
26+
27+
# Loading it requires the refresh call
28+
permission_registry.create_managed_roles(apps, update_perms=True)
29+
assert foo_permission in rd.permissions.all()
30+
31+
32+
@pytest.mark.django_db
33+
def test_remote_permission_load_update_roles():
34+
rd = RoleDefinition.objects.managed.org_admin
35+
DABContentType.objects.load_remote_objects(
36+
[
37+
{'service': 'fooland', 'app_label': 'foo', 'model': 'foo', 'api_slug': 'fooland.foo', 'parent_content_type': 'shared.organization'},
38+
{'service': 'fooland', 'app_label': 'foo', 'model': 'bar', 'api_slug': 'fooland.bar', 'parent_content_type': None},
39+
]
40+
)
41+
assert not DABPermission.objects.filter(codename='foo_foo').exists()
42+
DABPermission.objects.load_remote_objects(
43+
[
44+
{'codename': 'foo_foo', 'content_type': 'fooland.foo', 'api_slug': "fooland.foo_foo"},
45+
{'codename': 'bar_bar', 'content_type': 'fooland.bar', 'api_slug': "fooland.bar_bar"},
46+
],
47+
update_managed=True,
48+
)
49+
assert DABPermission.objects.filter(codename='foo_foo').exists()
50+
51+
foo_permission = DABPermission.objects.get(codename='foo_foo')
52+
bar_permission = DABPermission.objects.get(codename='bar_bar')
53+
assert foo_permission in rd.permissions.all()
54+
assert bar_permission not in rd.permissions.all() # not child type of organization
55+
56+
57+
@pytest.mark.django_db
58+
def test_remote_permission_duplicate_name():
59+
rd = RoleDefinition.objects.managed.org_admin
60+
DABContentType.objects.load_remote_objects(
61+
[
62+
{'service': 'foo', 'app_label': 'core', 'model': 'thing', 'api_slug': 'foo.thing', 'parent_content_type': 'shared.organization'},
63+
{'service': 'bar', 'app_label': 'core', 'model': 'thing', 'api_slug': 'bar.thing', 'parent_content_type': 'shared.organization'},
64+
]
65+
)
66+
ct = DABContentType.objects.get(api_slug='foo.thing')
67+
assert get_resource_prefix(ct.model_class()) == 'foo'
68+
69+
assert not DABPermission.objects.filter(codename='view_thing').exists()
70+
DABPermission.objects.load_remote_objects(
71+
[
72+
{'codename': 'view_thing', 'content_type': 'foo.thing', 'api_slug': "foo.view_thing"},
73+
{'codename': 'view_thing', 'content_type': 'bar.thing', 'api_slug': "bar.view_thing"},
74+
],
75+
update_managed=True,
76+
)
77+
assert DABPermission.objects.filter(codename='view_thing').count() == 2
78+
79+
foo_permission = DABPermission.objects.get(api_slug='foo.view_thing')
80+
bar_permission = DABPermission.objects.get(api_slug='bar.view_thing')
81+
# Both are valid permissions for the org_admin role
82+
assert foo_permission in rd.permissions.all()
83+
assert bar_permission in rd.permissions.all()

test_app/tests/rbac/remote/test_service_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_reload_types(admin_api_client):
6868

6969
DABContentType.objects.all().delete() # Delete all types, see if we get them back
7070

71-
DABContentType.objects.load_remote_types(type_list)
71+
DABContentType.objects.load_remote_objects(type_list)
7272

7373
response = admin_api_client.get(url + '?page_size=200', format="json")
7474
assert response.status_code == 200, response.data
@@ -78,7 +78,7 @@ def test_reload_types(admin_api_client):
7878

7979
@pytest.mark.django_db
8080
def test_load_child_of_org():
81-
DABContentType.objects.load_remote_types([{'service': 'fooland', 'app_label': 'foop', 'model': 'fooser', 'parent_content_type': 'shared.organization'}])
81+
DABContentType.objects.load_remote_objects([{'service': 'fooland', 'app_label': 'foop', 'model': 'fooser', 'parent_content_type': 'shared.organization'}])
8282
ct = DABContentType.objects.get(api_slug='fooland.fooser')
8383
assert ct.parent_content_type.app_label == 'test_app' # proves connection to existing
8484

@@ -94,7 +94,7 @@ def test_reload_permissions(admin_api_client):
9494

9595
DABPermission.objects.all().delete() # Delete all permissions, see if we get them back
9696

97-
DABPermission.objects.load_remote_types(perm_list)
97+
DABPermission.objects.load_remote_objects(perm_list)
9898

9999
response = admin_api_client.get(url + '?page_size=200', format="json")
100100
assert response.status_code == 200, response.data

0 commit comments

Comments
 (0)