AAP-47283 [2.6] Unified display of RBAC & synchronization (#7001)

* Working branch for testing DAB RBAC changes

* AAP-48392 Handle DAB RBAC either before or after new type model (for merge) (#16045)

* Handle DAB RBAC either before or after new type model

* Translate CT to DAB CT

* Fix for rearrangement of post_migration methods

* Directly include RBAC service URLs

* Add a run before remote permission additions

* Sync old rbac to remote rbac (#7025)

Signed-off-by: Seth Foster <fosterbseth@gmail.com>

* Set DAB requirement back to devel

---------

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
Co-authored-by: Seth Foster <fosterseth@users.noreply.github.com>
This commit is contained in:
Alan Rominger 2025-07-29 16:31:29 -04:00 committed by thedoubl3j
parent a3f2401740
commit c5fb0c351d
No known key found for this signature in database
GPG Key ID: E84C42ACF75B0768
16 changed files with 115 additions and 36 deletions

View File

@ -64,6 +64,7 @@ from ansible_base.lib.utils.requests import get_remote_hosts
from ansible_base.rbac.models import RoleEvaluation
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType
from ansible_base.rbac.models import RoleEvaluation, ObjectRole
from ansible_base.rbac import permission_registry
# AWX
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields

View File

@ -8,7 +8,7 @@ from awx.main.migrations._dab_rbac import migrate_to_new_rbac, create_permission
class Migration(migrations.Migration):
dependencies = [
('main', '0191_add_django_permissions'),
('dab_rbac', '__first__'),
('dab_rbac', '0003_alter_dabpermission_codename_and_more'),
]
operations = [

View File

@ -12,6 +12,10 @@ class Migration(migrations.Migration):
dependencies = [
('main', '0202_convert_controller_role_definitions'),
]
# The DAB RBAC app makes substantial model changes which by change-ordering comes after this
# not including run_before might sometimes work but this enforces a more strict and stable order
# for both applying migrations forwards and backwards
run_before = [("dab_rbac", "0004_remote_permissions_additions")]
operations = [
migrations.RunPython(consolidate_indirect_user_roles, migrations.RunPython.noop),

View File

@ -18,7 +18,14 @@ logger = logging.getLogger('awx.main.migrations._dab_rbac')
def create_permissions_as_operation(apps, schema_editor):
logger.info('Running data migration create_permissions_as_operation')
# NOTE: the DAB ContentType changes adjusted how they fire
# before they would fire on every app config, like contenttypes
create_dab_permissions(global_apps.get_app_config("main"), apps=apps)
# This changed to only fire once and do a global creation
# so we need to call it for specifically the dab_rbac app
# multiple calls will not hurt anything
create_dab_permissions(global_apps.get_app_config("dab_rbac"), apps=apps)
"""
@ -113,7 +120,12 @@ def get_descendents(f, children_map):
def get_permissions_for_role(role_field, children_map, apps):
Permission = apps.get_model('dab_rbac', 'DABPermission')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
# After migration for remote permissions
ContentType = apps.get_model('dab_rbac', 'DABContentType')
except LookupError:
# If using DAB from before remote permissions are implemented
ContentType = apps.get_model('contenttypes', 'ContentType')
perm_list = []
for child_field in get_descendents(role_field, children_map):
@ -156,11 +168,15 @@ def migrate_to_new_rbac(apps, schema_editor):
This method moves the assigned permissions from the old rbac.py models
to the new RoleDefinition and ObjectRole models
"""
logger.info('Running data migration migrate_to_new_rbac')
Role = apps.get_model('main', 'Role')
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
Permission = apps.get_model('dab_rbac', 'DABPermission')
if Permission.objects.count() == 0:
raise RuntimeError('Running migrate_to_new_rbac requires DABPermission objects created first')
# remove add premissions that are not valid for migrations from old versions
for perm_str in ('add_organization', 'add_jobtemplate'):
perm = Permission.objects.filter(codename=perm_str).first()
@ -278,6 +294,7 @@ def setup_managed_role_definitions(apps, schema_editor):
"""
Idempotent method to create or sync the managed role definitions
"""
logger.info('Running data migration setup_managed_role_definitions')
to_create = {
'object_admin': '{cls.__name__} Admin',
'org_admin': 'Organization Admin',
@ -285,7 +302,13 @@ def setup_managed_role_definitions(apps, schema_editor):
'special': '{cls.__name__} {action}',
}
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
# After migration for remote permissions
ContentType = apps.get_model('dab_rbac', 'DABContentType')
except LookupError:
# If using DAB from before remote permissions are implemented
ContentType = apps.get_model('contenttypes', 'ContentType')
Permission = apps.get_model('dab_rbac', 'DABPermission')
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
Organization = apps.get_model(settings.ANSIBLE_BASE_ORGANIZATION_MODEL)

View File

@ -86,7 +86,7 @@ class ResourceMixin(models.Model):
raise RuntimeError(f'Role filters only valid for users and ancestor role, received {accessor}')
if content_types is None:
ct_kwarg = dict(content_type_id=ContentType.objects.get_for_model(cls).id)
ct_kwarg = dict(content_type=ContentType.objects.get_for_model(cls))
else:
ct_kwarg = dict(content_type_id__in=content_types)

View File

@ -27,6 +27,8 @@ from django.conf import settings
# Ansible_base app
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment, RoleTeamAssignment
from ansible_base.rbac.sync import maybe_reverse_sync_assignment, maybe_reverse_sync_unassignment
from ansible_base.rbac import permission_registry
from ansible_base.lib.utils.models import get_type_for_model
# AWX
@ -562,7 +564,7 @@ def get_role_definition(role):
rd_name = f'{model_print} {action_name.title()} Compat'
perm_list = get_role_codenames(role)
defaults = {
'content_type_id': role.content_type_id,
'content_type': permission_registry.content_type_model.objects.get_by_natural_key(role.content_type.app_label, role.content_type.model),
'description': f'Has {action_name.title()} permission to {model_print} for backwards API compatibility',
}
@ -614,12 +616,14 @@ def get_role_from_object_role(object_role):
return getattr(object_role.content_object, role_name)
def give_or_remove_permission(role, actor, giving=True):
def give_or_remove_permission(role, actor, giving=True, rd=None):
obj = role.content_object
if obj is None:
return
rd = get_role_definition(role)
rd.give_or_remove_permission(actor, obj, giving=giving)
if not rd:
rd = get_role_definition(role)
assignment = rd.give_or_remove_permission(actor, obj, giving=giving)
return assignment
class SyncEnabled(threading.local):
@ -671,7 +675,14 @@ def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
role = Role.objects.get(pk=user_or_role_id)
else:
user = get_user_model().objects.get(pk=user_or_role_id)
give_or_remove_permission(role, user, giving=is_giving)
rd = get_role_definition(role)
assignment = give_or_remove_permission(role, user, giving=is_giving, rd=rd)
# sync to resource server
if is_giving:
maybe_reverse_sync_assignment(assignment)
else:
maybe_reverse_sync_unassignment(rd, user, role.content_object)
def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
@ -714,7 +725,14 @@ def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs)
from awx.main.models.organization import Team
team = Team.objects.get(pk=parent_role.object_id)
give_or_remove_permission(child_role, team, giving=is_giving)
rd = get_role_definition(child_role)
assignment = give_or_remove_permission(child_role, team, giving=is_giving, rd=rd)
# sync to resource server
if is_giving:
maybe_reverse_sync_assignment(assignment)
else:
maybe_reverse_sync_unassignment(rd, team, child_role.content_object)
ROLE_DEFINITION_TO_ROLE_FIELD = {

View File

@ -33,6 +33,7 @@ from polymorphic.models import PolymorphicModel
from ansible_base.lib.utils.models import prevent_search, get_type_for_model
from ansible_base.rbac import permission_registry
from ansible_base.rbac.models import RoleEvaluation
# AWX
from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel, NotificationFieldsModel
@ -217,20 +218,21 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
# do not use this if in a subclass
if cls != UnifiedJobTemplate:
return super(UnifiedJobTemplate, cls).accessible_pk_qs(accessor, role_field)
from ansible_base.rbac.models import RoleEvaluation
action = to_permissions[role_field]
# Special condition for super auditor
role_subclasses = cls._submodels_with_roles()
role_cts = ContentType.objects.get_for_models(*role_subclasses).values()
all_codenames = {f'{action}_{cls._meta.model_name}' for cls in role_subclasses}
if not (all_codenames - accessor.singleton_permissions()):
role_cts = ContentType.objects.get_for_models(*role_subclasses).values()
qs = cls.objects.filter(polymorphic_ctype__in=role_cts)
return qs.values_list('id', flat=True)
dab_role_cts = permission_registry.content_type_model.objects.get_for_models(*role_subclasses).values()
return (
RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__in=all_codenames, content_type_id__in=[ct.id for ct in role_cts])
RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__in=all_codenames, content_type_id__in=[ct.id for ct in dab_role_cts])
.values_list('object_id')
.distinct()
)

View File

@ -1,3 +1,5 @@
import logging
# Python
import pytest
from unittest import mock
@ -8,7 +10,7 @@ import importlib
# Django
from django.urls import resolve
from django.http import Http404
from django.apps import apps
from django.apps import apps as global_apps
from django.core.handlers.exception import response_for_exception
from django.contrib.auth.models import User
from django.core.serializers.json import DjangoJSONEncoder
@ -48,6 +50,8 @@ from awx.main.models.oauth import OAuth2Application as Application
from awx.main.models.execution_environments import ExecutionEnvironment
from awx.main.utils import is_testing
logger = logging.getLogger(__name__)
__SWAGGER_REQUESTS__ = {}
@ -55,8 +59,17 @@ __SWAGGER_REQUESTS__ = {}
dab_rr_initial = importlib.import_module('ansible_base.resource_registry.migrations.0001_initial')
def create_service_id(app_config, apps=global_apps, **kwargs):
try:
apps.get_model("dab_resource_registry", "ServiceID")
except LookupError:
logger.info('Looks like reverse migration, not creating resource registry ServiceID')
return
dab_rr_initial.create_service_id(apps, None)
if is_testing():
post_migrate.connect(lambda **kwargs: dab_rr_initial.create_service_id(apps, None))
post_migrate.connect(create_service_id)
@pytest.fixture(scope="session")
@ -127,7 +140,7 @@ def execution_environment():
@pytest.fixture
def setup_managed_roles():
"Run the migration script to pre-create managed role definitions"
setup_managed_role_definitions(apps, None)
setup_managed_role_definitions(global_apps, None)
@pytest.fixture

View File

@ -1,6 +1,5 @@
import pytest
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse as django_reverse
from awx.api.versioning import reverse
@ -8,13 +7,14 @@ from awx.main.models import JobTemplate, Inventory, Organization
from awx.main.access import JobTemplateAccess, WorkflowJobTemplateAccess
from ansible_base.rbac.models import RoleDefinition
from ansible_base.rbac import permission_registry
@pytest.mark.django_db
def test_managed_roles_created(setup_managed_roles):
"Managed RoleDefinitions are created in post_migration signal, we expect to see them here"
for cls in (JobTemplate, Inventory):
ct = ContentType.objects.get_for_model(cls)
ct = permission_registry.content_type_model.objects.get_for_model(cls)
rds = list(RoleDefinition.objects.filter(content_type=ct))
assert len(rds) > 1
assert f'{cls.__name__} Admin' in [rd.name for rd in rds]
@ -26,17 +26,20 @@ def test_managed_roles_created(setup_managed_roles):
def test_custom_read_role(admin_user, post, setup_managed_roles):
rd_url = django_reverse('roledefinition-list')
resp = post(
url=rd_url, data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['view_inventory']}, user=admin_user, expect=201
url=rd_url,
data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['awx.view_inventory']},
user=admin_user,
expect=201,
)
rd_id = resp.data['id']
rd = RoleDefinition.objects.get(id=rd_id)
assert rd.content_type == ContentType.objects.get_for_model(Inventory)
assert rd.content_type == permission_registry.content_type_model.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)
resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['awx.view_inventory']}, user=admin_user, expect=400)
assert 'System-wide roles are not enabled' in str(resp.data)
@ -71,7 +74,7 @@ def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch):
rd, _ = RoleDefinition.objects.get_or_create(
name='inventory-delete',
permissions=['delete_inventory', 'view_inventory', 'change_inventory'],
content_type=ContentType.objects.get_for_model(Inventory),
content_type=permission_registry.content_type_model.objects.get_for_model(Inventory),
)
rd.give_permission(rando, inventory)
inv_id = inventory.pk
@ -85,7 +88,9 @@ def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch):
@pytest.mark.django_db
def test_assign_custom_add_role(admin_user, rando, organization, post, setup_managed_roles):
rd, _ = RoleDefinition.objects.get_or_create(
name='inventory-add', permissions=['add_inventory', 'view_organization'], content_type=ContentType.objects.get_for_model(Organization)
name='inventory-add',
permissions=['add_inventory', 'view_organization'],
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
)
rd.give_permission(rando, organization)
url = reverse('api:inventory_list')

View File

@ -15,6 +15,14 @@ def test_roles_to_not_create(setup_managed_roles):
raise Exception(f'Found RoleDefinitions that should not exist: {bad_names}')
@pytest.mark.django_db
def test_org_admin_role(setup_managed_roles):
rd = RoleDefinition.objects.get(name='Organization Admin')
codenames = list(rd.permissions.values_list('codename', flat=True))
assert 'view_inventory' in codenames
assert 'change_inventory' in codenames
@pytest.mark.django_db
def test_project_update_role(setup_managed_roles):
"""Role to allow updating a project on the object-level should exist"""

View File

@ -3,8 +3,6 @@ import json
import pytest
from django.contrib.contenttypes.models import ContentType
from crum import impersonate
from awx.main.fields import ImplicitRoleField
@ -60,7 +58,7 @@ def test_role_migration_matches(request, model, setup_managed_roles):
new_codenames = set(rd.permissions.values_list('codename', flat=True))
# all the old roles should map to a non-Compat role definition
if 'Compat' not in rd.name:
model_rds = RoleDefinition.objects.filter(content_type=ContentType.objects.get_for_model(obj))
model_rds = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get_for_model(obj))
rd_data = {}
for rd in model_rds:
rd_data[rd.name] = list(rd.permissions.values_list('codename', flat=True))
@ -76,7 +74,7 @@ def test_role_migration_matches(request, model, setup_managed_roles):
@pytest.mark.django_db
def test_role_naming(setup_managed_roles):
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='dmin')
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='dmin')
assert qs.count() == 1 # sanity
rd = qs.first()
assert rd.name == 'JobTemplate Admin'
@ -86,7 +84,7 @@ def test_role_naming(setup_managed_roles):
@pytest.mark.django_db
def test_action_role_naming(setup_managed_roles):
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ecute')
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='ecute')
assert qs.count() == 1 # sanity
rd = qs.first()
assert rd.name == 'JobTemplate Execute'
@ -98,7 +96,7 @@ def test_action_role_naming(setup_managed_roles):
def test_compat_role_naming(setup_managed_roles, job_template, rando, alice):
with impersonate(alice):
job_template.read_role.members.add(rando)
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ompat')
qs = RoleDefinition.objects.filter(content_type=permission_registry.content_type_model.objects.get(model='jobtemplate'), name__endswith='ompat')
assert qs.count() == 1 # sanity
rd = qs.first()
assert rd.name == 'JobTemplate Read Compat'

View File

@ -1,7 +1,5 @@
import pytest
from django.contrib.contenttypes.models import ContentType
from awx.main.access import ExecutionEnvironmentAccess
from awx.main.models import ExecutionEnvironment, Organization, Team
from awx.main.models.rbac import get_role_codenames
@ -10,6 +8,7 @@ from awx.api.versioning import reverse
from django.urls import reverse as django_reverse
from ansible_base.rbac.models import RoleDefinition
from ansible_base.rbac import permission_registry
@pytest.fixture
@ -17,7 +16,7 @@ def ee_rd():
return RoleDefinition.objects.create_from_permissions(
name='EE object admin',
permissions=['change_executionenvironment', 'delete_executionenvironment'],
content_type=ContentType.objects.get_for_model(ExecutionEnvironment),
content_type=permission_registry.content_type_model.objects.get_for_model(ExecutionEnvironment),
)
@ -26,7 +25,7 @@ def org_ee_rd():
return RoleDefinition.objects.create_from_permissions(
name='EE org admin',
permissions=['add_executionenvironment', 'change_executionenvironment', 'delete_executionenvironment', 'view_organization'],
content_type=ContentType.objects.get_for_model(Organization),
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
)

View File

@ -1,5 +1,7 @@
from ansible_base.resource_registry.registry import ParentResource, ResourceConfig, ServiceAPIConfig, SharedResource
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType
from ansible_base.rbac.models import RoleDefinition
from ansible_base.resource_registry.shared_types import RoleDefinitionType
from awx.main import models
@ -19,4 +21,8 @@ RESOURCE_LIST = (
shared_resource=SharedResource(serializer=TeamType, is_provider=False),
parent_resources=[ParentResource(model=models.Organization, field_name="organization")],
),
ResourceConfig(
RoleDefinition,
shared_resource=SharedResource(serializer=RoleDefinitionType, is_provider=False),
),
)

View File

@ -5,6 +5,7 @@ from django.conf import settings
from django.urls import re_path, include, path
from ansible_base.lib.dynamic_config.dynamic_urls import api_urls, api_version_urls, root_urls
from ansible_base.rbac.service_api.urls import rbac_service_urls
from ansible_base.resource_registry.urls import urlpatterns as resource_api_urls
@ -25,6 +26,7 @@ def get_urlpatterns(prefix=None):
urlpatterns += [
path(f'api{prefix}v2/', include(resource_api_urls)),
path(f'api{prefix}v2/', include(rbac_service_urls)),
path(f'api{prefix}v2/', include(api_version_urls)),
path(f'api{prefix}', include(api_urls)),
path('', include(root_urls)),

View File

@ -18,6 +18,7 @@ import pytest
from ansible.module_utils.six import raise_from
from ansible_base.rbac.models import RoleDefinition, DABPermission
from ansible_base.rbac import permission_registry
from awx.main.tests.functional.conftest import _request
from awx.main.tests.functional.conftest import credentialtype_scm, credentialtype_ssh # noqa: F401; pylint: disable=unused-import
from awx.main.models import (
@ -36,7 +37,6 @@ from awx.main.models import (
)
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
HAS_TOWER_CLI = False
@ -341,7 +341,7 @@ def notification_template(organization):
@pytest.fixture
def job_template_role_definition():
rd = RoleDefinition.objects.create(name='test_view_jt', content_type=ContentType.objects.get_for_model(JobTemplate))
rd = RoleDefinition.objects.create(name='test_view_jt', content_type=permission_registry.content_type_model.objects.get_for_model(JobTemplate))
permission_codenames = ['view_jobtemplate', 'execute_jobtemplate']
permissions = DABPermission.objects.filter(codename__in=permission_codenames)
rd.permissions.add(*permissions)

View File

@ -1,4 +1,4 @@
git+https://github.com/ansible/system-certifi.git@devel#egg=certifi
# Remove pbr from requirements.in when moving ansible-runner to requirements.in
git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml
django-ansible-base @ git+ssh://git@github.com/ansible-automation-platform/django-ansible-base@devel#egg=django-ansible-base[rest-filters,jwt_consumer,resource-registry,rbac,feature-flags]
django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest-filters,jwt_consumer,resource-registry,rbac,feature-flags]