diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 73e6e9b889..665124181e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -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 diff --git a/awx/main/migrations/0192_custom_roles.py b/awx/main/migrations/0192_custom_roles.py index c91823aa34..ba75694c7f 100644 --- a/awx/main/migrations/0192_custom_roles.py +++ b/awx/main/migrations/0192_custom_roles.py @@ -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 = [ diff --git a/awx/main/migrations/0203_remove_team_of_teams.py b/awx/main/migrations/0203_remove_team_of_teams.py index c5a3da0340..905f5a40c4 100644 --- a/awx/main/migrations/0203_remove_team_of_teams.py +++ b/awx/main/migrations/0203_remove_team_of_teams.py @@ -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), diff --git a/awx/main/migrations/_dab_rbac.py b/awx/main/migrations/_dab_rbac.py index 1d64ba85c9..e8b91e4e88 100644 --- a/awx/main/migrations/_dab_rbac.py +++ b/awx/main/migrations/_dab_rbac.py @@ -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) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 373271aed8..4215341b0e 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -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) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 775926a0c7..182ad5115d 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -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 = { diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 146d2972d8..7b1f8990aa 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -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() ) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 17780b0857..00caf80377 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -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 diff --git a/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py b/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py index 793eeccbf4..3b09272d8c 100644 --- a/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py +++ b/awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py @@ -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') diff --git a/awx/main/tests/functional/dab_rbac/test_managed_roles.py b/awx/main/tests/functional/dab_rbac/test_managed_roles.py index 22fc16935c..82fd661fa5 100644 --- a/awx/main/tests/functional/dab_rbac/test_managed_roles.py +++ b/awx/main/tests/functional/dab_rbac/test_managed_roles.py @@ -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""" diff --git a/awx/main/tests/functional/dab_rbac/test_translation_layer.py b/awx/main/tests/functional/dab_rbac/test_translation_layer.py index 7972890211..dbbc4926e9 100644 --- a/awx/main/tests/functional/dab_rbac/test_translation_layer.py +++ b/awx/main/tests/functional/dab_rbac/test_translation_layer.py @@ -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' diff --git a/awx/main/tests/functional/test_rbac_execution_environment.py b/awx/main/tests/functional/test_rbac_execution_environment.py index 8749574389..b6d98f073b 100644 --- a/awx/main/tests/functional/test_rbac_execution_environment.py +++ b/awx/main/tests/functional/test_rbac_execution_environment.py @@ -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), ) diff --git a/awx/resource_api.py b/awx/resource_api.py index 2009dfab8b..f169bf9c5a 100644 --- a/awx/resource_api.py +++ b/awx/resource_api.py @@ -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), + ), ) diff --git a/awx/urls.py b/awx/urls.py index 876041b821..05e97637b6 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -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)), diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 42500342ac..f525f6a636 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -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) diff --git a/requirements/requirements_git.txt b/requirements/requirements_git.txt index 791e0b90ca..72a046f335 100644 --- a/requirements/requirements_git.txt +++ b/requirements/requirements_git.txt @@ -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]