[DAB RBAC] Re-implement system auditor as a singleton role in new system (#14963)

* Add new enablement settings from DAB RBAC

* Initial implementation of system auditor as role without testing

* Fix system auditor role, remove duplicate assignments

* Make the system auditor role managed

* Flake8 fix

* Remove another thing from old solution

* Fix a few test failures

* Add extra setting to disable custom system roles via API

* Add test for custom role prohibition
This commit is contained in:
Alan Rominger
2024-03-11 12:16:49 -04:00
parent 74ce21fa54
commit 9dcc11d54c
15 changed files with 70 additions and 47 deletions

View File

@@ -971,7 +971,6 @@ class CopyAPIView(GenericAPIView):
None, None, self.model, obj, request.user, create_kwargs=create_kwargs, copy_name=serializer.validated_data.get('name', '') None, None, self.model, obj, request.user, create_kwargs=create_kwargs, copy_name=serializer.validated_data.get('name', '')
) )
if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all(): if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all():
new_obj.admin_role.members.add(request.user)
give_creator_permissions(request.user, new_obj) give_creator_permissions(request.user, new_obj)
if sub_objs: if sub_objs:
permission_check_func = None permission_check_func = None

View File

@@ -3142,7 +3142,6 @@ class CredentialSerializerCreate(CredentialSerializer):
credential = super(CredentialSerializerCreate, self).create(validated_data) credential = super(CredentialSerializerCreate, self).create(validated_data)
if user: if user:
credential.admin_role.members.add(user)
give_creator_permissions(user, credential) give_creator_permissions(user, credential)
if team: if team:
if not credential.organization or team.organization.id != credential.organization.id: if not credential.organization or team.organization.id != credential.organization.id:

View File

@@ -90,7 +90,7 @@ from awx.api.generics import (
from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.views.labels import LabelSubListCreateAttachDetachView
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main import models from awx.main import models
from awx.main.models.rbac import give_creator_permissions, get_role_definition from awx.main.models.rbac import get_role_definition
from awx.main.utils import ( from awx.main.utils import (
camelcase_to_underscore, camelcase_to_underscore,
extract_ansible_vars, extract_ansible_vars,
@@ -2314,14 +2314,6 @@ class JobTemplateList(ListCreateAPIView):
serializer_class = serializers.JobTemplateSerializer serializer_class = serializers.JobTemplateSerializer
always_allow_superuser = False always_allow_superuser = False
def post(self, request, *args, **kwargs):
ret = super(JobTemplateList, self).post(request, *args, **kwargs)
if ret.status_code == 201:
job_template = models.JobTemplate.objects.get(id=ret.data['id'])
job_template.admin_role.members.add(request.user)
give_creator_permissions(request.user, job_template)
return ret
class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = models.JobTemplate model = models.JobTemplate

View File

@@ -652,7 +652,6 @@ class UserAccess(BaseAccess):
User.objects.filter(pk__in=Organization.accessible_objects(self.user, 'read_role').values('member_role__members')) User.objects.filter(pk__in=Organization.accessible_objects(self.user, 'read_role').values('member_role__members'))
| User.objects.filter(pk=self.user.id) | User.objects.filter(pk=self.user.id)
| User.objects.filter(is_superuser=True) | User.objects.filter(is_superuser=True)
| User.objects.filter(profile__is_system_auditor=True)
).distinct() ).distinct()
return qs return qs

View File

@@ -7,6 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('main', '0189_inbound_hop_nodes'), ('main', '0189_inbound_hop_nodes'),
('dab_rbac', '__first__'),
] ]
operations = [ operations = [

View File

@@ -9,7 +9,7 @@ from ansible_base.rbac.migrations._managed_definitions import setup_managed_role
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('main', '0191_profile_is_system_auditor'), ('main', '0190_add_django_permissions'),
('dab_rbac', '__first__'), ('dab_rbac', '__first__'),
] ]

View File

@@ -1,20 +0,0 @@
# Generated by Django 4.2.6 on 2023-11-20 16:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0190_add_django_permissions'),
]
run_before = [
('dab_rbac', '__first__'),
]
operations = [
migrations.AddField(
model_name='profile',
name='is_system_auditor',
field=models.BooleanField(default=False, help_text='Can view everying in the system, proxies to User model'),
),
]

View File

@@ -144,6 +144,7 @@ def migrate_to_new_rbac(apps, schema_editor):
""" """
Role = apps.get_model('main', 'Role') Role = apps.get_model('main', 'Role')
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition') RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
Permission = apps.get_model('auth', 'Permission') Permission = apps.get_model('auth', 'Permission')
migration_time = now() migration_time = now()
@@ -224,14 +225,25 @@ def migrate_to_new_rbac(apps, schema_editor):
content_type_id=role.content_type_id, content_type_id=role.content_type_id,
) )
# Create new replacement system auditor role
new_system_auditor, created = RoleDefinition.objects.get_or_create(
name='System Auditor',
defaults={
'description': 'Migrated singleton role giving read permission to everything',
'managed': True,
'created_on': migration_time,
'modified_on': migration_time,
},
)
new_system_auditor.permissions.add(*list(Permission.objects.filter(codename__startswith='view')))
# migrate is_system_auditor flag, because it is no longer handled by a system role # migrate is_system_auditor flag, because it is no longer handled by a system role
role = Role.objects.filter(singleton_name='system_auditor').first() old_system_auditor = Role.objects.filter(singleton_name='system_auditor').first()
if role: if old_system_auditor:
# if the system auditor role is not present, this is a new install and no users should exist # if the system auditor role is not present, this is a new install and no users should exist
ct = 0 ct = 0
for user in role.members.all(): for user in role.members.all():
user.profile.is_system_auditor = True RoleUserAssignment.objects.create(user=user, role_definition=new_system_auditor)
user.profile.save(update_fields=['is_system_auditor'])
ct += 1 ct += 1
if ct: if ct:
logger.info(f'Migrated {ct} users to new system auditor flag') logger.info(f'Migrated {ct} users to new system auditor flag')

View File

@@ -11,6 +11,7 @@ from django.db.models.signals import pre_delete # noqa
# django-ansible-base # django-ansible-base
from ansible_base.resource_registry.fields import AnsibleResourceField from ansible_base.resource_registry.fields import AnsibleResourceField
from ansible_base.rbac import permission_registry from ansible_base.rbac import permission_registry
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment
from ansible_base.lib.utils.models import prevent_search from ansible_base.lib.utils.models import prevent_search
from ansible_base.lib.utils.models import user_summary_fields from ansible_base.lib.utils.models import user_summary_fields
@@ -199,11 +200,21 @@ User.add_to_class('auditor_of_organizations', user_get_auditor_of_organizations)
User.add_to_class('created', created) User.add_to_class('created', created)
def get_system_auditor_role():
rd, created = RoleDefinition.objects.get_or_create(
name='System Auditor', defaults={'description': 'Migrated singleton role giving read permission to everything'}
)
if created:
rd.permissions.add(*list(permission_registry.permission_qs.filter(codename__startswith='view')))
return rd
@property @property
def user_is_system_auditor(user): def user_is_system_auditor(user):
if not hasattr(user, '_is_system_auditor'): if not hasattr(user, '_is_system_auditor'):
if user.pk: if user.pk:
user._is_system_auditor = user.profile.is_system_auditor rd = get_system_auditor_role()
user._is_system_auditor = RoleUserAssignment.objects.filter(user=user, role_definition=rd).exists()
else: else:
# Odd case where user is unsaved, this should never be relied on # Odd case where user is unsaved, this should never be relied on
return False return False
@@ -217,11 +228,15 @@ def user_is_system_auditor(user, tf):
# time they've logged in, and we've just created the new User in this # time they've logged in, and we've just created the new User in this
# request), we need one to set up the system auditor role # request), we need one to set up the system auditor role
user.save() user.save()
if user.profile.is_system_auditor != bool(tf): rd = get_system_auditor_role()
prior_value = user.profile.is_system_auditor assignment = RoleUserAssignment.objects.filter(user=user, role_definition=rd).first()
user.profile.is_system_auditor = bool(tf) prior_value = bool(assignment)
user.profile.save(update_fields=['is_system_auditor']) if prior_value != bool(tf):
user._is_system_auditor = user.profile.is_system_auditor if assignment:
assignment.delete()
else:
rd.give_global_permission(user)
user._is_system_auditor = bool(tf)
entry = ActivityStream.objects.create(changes=json.dumps({"is_system_auditor": [prior_value, bool(tf)]}), object1='user', operation='update') entry = ActivityStream.objects.create(changes=json.dumps({"is_system_auditor": [prior_value, bool(tf)]}), object1='user', operation='update')
entry.user.add(user) entry.user.add(user)

View File

@@ -182,7 +182,6 @@ class Profile(CreatedModifiedModel):
max_length=1024, max_length=1024,
default='', default='',
) )
is_system_auditor = models.BooleanField(default=False, help_text=_('Can view everying in the system, proxies to User model'))
class UserSessionMembership(BaseModel): class UserSessionMembership(BaseModel):

View File

@@ -7,6 +7,9 @@ import threading
import contextlib import contextlib
import re import re
# django-rest-framework
from rest_framework.serializers import ValidationError
# Django # Django
from django.db import models, transaction, connection from django.db import models, transaction, connection
from django.db.models.signals import m2m_changed from django.db.models.signals import m2m_changed
@@ -552,7 +555,15 @@ def get_role_definition(role):
action_name = f.name.rsplit("_", 1)[0] action_name = f.name.rsplit("_", 1)[0]
rd_name = f'{obj._meta.model_name}-{action_name}-compat' rd_name = f'{obj._meta.model_name}-{action_name}-compat'
perm_list = get_role_codenames(role) perm_list = get_role_codenames(role)
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults={'content_type_id': role.content_type_id}) defaults = {'content_type_id': role.content_type_id}
try:
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
except ValidationError:
# This is a tricky case - practically speaking, users should not be allowed to create team roles
# or roles that include the team member permission.
# If we need to create this for compatibility purposes then we will create it as a managed non-editable role
defaults['managed'] = True
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
return rd return rd

View File

@@ -32,6 +32,13 @@ def test_custom_read_role(admin_user, post):
assert rd.content_type == ContentType.objects.get_for_model(Inventory) assert rd.content_type == ContentType.objects.get_for_model(Inventory)
@pytest.mark.django_db
def test_custom_system_roles_prohibited(admin_user, post):
rd_url = django_reverse('roledefinition-list')
resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['view_inventory']}, user=admin_user, expect=400)
assert 'System-wide roles are not enabled' in str(resp.data)
@pytest.mark.django_db @pytest.mark.django_db
def test_assign_managed_role(admin_user, alice, rando, inventory, post): def test_assign_managed_role(admin_user, alice, rando, inventory, post):
rd = RoleDefinition.objects.get(name='inventory-admin') rd = RoleDefinition.objects.get(name='inventory-admin')

View File

@@ -177,7 +177,7 @@ def test_job_template_creator_access(project, organization, rando, post):
jt_pk = response.data['id'] jt_pk = response.data['id']
jt_obj = JobTemplate.objects.get(pk=jt_pk) jt_obj = JobTemplate.objects.get(pk=jt_pk)
# Creating a JT should place the creator in the admin role # Creating a JT should place the creator in the admin role
assert rando in jt_obj.admin_role.members.all() assert rando in jt_obj.admin_role
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -4,7 +4,7 @@ from unittest import mock
from django.test import TransactionTestCase from django.test import TransactionTestCase
from awx.main.access import UserAccess, RoleAccess, TeamAccess from awx.main.access import UserAccess, RoleAccess, TeamAccess
from awx.main.models import User, Organization, Inventory from awx.main.models import User, Organization, Inventory, get_system_auditor_role
class TestSysAuditorTransactional(TransactionTestCase): class TestSysAuditorTransactional(TransactionTestCase):
@@ -18,6 +18,7 @@ class TestSysAuditorTransactional(TransactionTestCase):
def test_auditor_caching(self): def test_auditor_caching(self):
rando = self.rando() rando = self.rando()
get_system_auditor_role() # pre-create role, normally done by migrations
with self.assertNumQueries(2): with self.assertNumQueries(2):
v = rando.is_system_auditor v = rando.is_system_auditor
assert not v assert not v

View File

@@ -1164,5 +1164,13 @@ ANSIBLE_BASE_SERVICE_PREFIX = "awx"
# Temporary, for old roles API compatibility, save child permissions at organization level # Temporary, for old roles API compatibility, save child permissions at organization level
ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True
# Currently features are enabled to keep compatibility with old system, except custom roles
ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN = False
# ANSIBLE_BASE_ALLOW_CUSTOM_ROLES = True
ANSIBLE_BASE_ALLOW_CUSTOM_TEAM_ROLES = False
ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES = True
ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = False # System auditor has always been restricted to users
ANSIBLE_BASE_ALLOW_SINGLETON_ROLES_API = False # Do not allow creating user-defined system-wide roles
# system username for django-ansible-base # system username for django-ansible-base
SYSTEM_USERNAME = None SYSTEM_USERNAME = None