Compare commits

..

1 Commits

Author SHA1 Message Date
Jeff Bradberry
6d0a3149f1 Create and register page types for the new RBAC endpoints 2024-06-14 14:48:05 -04:00
17 changed files with 147 additions and 317 deletions

View File

@@ -64,9 +64,6 @@ DEV_DOCKER_OWNER_LOWER = $(shell echo $(DEV_DOCKER_OWNER) | tr A-Z a-z)
DEV_DOCKER_TAG_BASE ?= ghcr.io/$(DEV_DOCKER_OWNER_LOWER) DEV_DOCKER_TAG_BASE ?= ghcr.io/$(DEV_DOCKER_OWNER_LOWER)
DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
# Common command to use for running ansible-playbook
ANSIBLE_PLAYBOOK ?= ansible-playbook -e ansible_python_interpreter=$(PYTHON)
RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel
# Python packages to install only from source (not from binary wheels) # Python packages to install only from source (not from binary wheels)
@@ -371,7 +368,7 @@ symlink_collection:
ln -s $(shell pwd)/awx_collection $(COLLECTION_INSTALL) ln -s $(shell pwd)/awx_collection $(COLLECTION_INSTALL)
awx_collection_build: $(shell find awx_collection -type f) awx_collection_build: $(shell find awx_collection -type f)
$(ANSIBLE_PLAYBOOK) -i localhost, awx_collection/tools/template_galaxy.yml \ ansible-playbook -i localhost, awx_collection/tools/template_galaxy.yml \
-e collection_package=$(COLLECTION_PACKAGE) \ -e collection_package=$(COLLECTION_PACKAGE) \
-e collection_namespace=$(COLLECTION_NAMESPACE) \ -e collection_namespace=$(COLLECTION_NAMESPACE) \
-e collection_version=$(COLLECTION_VERSION) \ -e collection_version=$(COLLECTION_VERSION) \
@@ -525,10 +522,10 @@ endif
docker-compose-sources: .git/hooks/pre-commit docker-compose-sources: .git/hooks/pre-commit
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\ @if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
$(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \ ansible-playbook -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \
fi; fi;
$(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory tools/docker-compose/ansible/sources.yml \ ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/sources.yml \
-e awx_image=$(DEV_DOCKER_TAG_BASE)/awx_devel \ -e awx_image=$(DEV_DOCKER_TAG_BASE)/awx_devel \
-e awx_image_tag=$(COMPOSE_TAG) \ -e awx_image_tag=$(COMPOSE_TAG) \
-e receptor_image=$(RECEPTOR_IMAGE) \ -e receptor_image=$(RECEPTOR_IMAGE) \
@@ -552,7 +549,7 @@ docker-compose-sources: .git/hooks/pre-commit
docker-compose: awx/projects docker-compose-sources docker-compose: awx/projects docker-compose-sources
ansible-galaxy install --ignore-certs -r tools/docker-compose/ansible/requirements.yml; ansible-galaxy install --ignore-certs -r tools/docker-compose/ansible/requirements.yml;
$(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory tools/docker-compose/ansible/initialize_containers.yml \ ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/initialize_containers.yml \
-e enable_vault=$(VAULT) \ -e enable_vault=$(VAULT) \
-e vault_tls=$(VAULT_TLS) \ -e vault_tls=$(VAULT_TLS) \
-e enable_ldap=$(LDAP); \ -e enable_ldap=$(LDAP); \
@@ -595,7 +592,7 @@ docker-compose-container-group-clean:
.PHONY: Dockerfile.dev .PHONY: Dockerfile.dev
## Generate Dockerfile.dev for awx_devel image ## Generate Dockerfile.dev for awx_devel image
Dockerfile.dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2 Dockerfile.dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
$(ANSIBLE_PLAYBOOK) tools/ansible/dockerfile.yml \ ansible-playbook tools/ansible/dockerfile.yml \
-e dockerfile_name=Dockerfile.dev \ -e dockerfile_name=Dockerfile.dev \
-e build_dev=True \ -e build_dev=True \
-e receptor_image=$(RECEPTOR_IMAGE) -e receptor_image=$(RECEPTOR_IMAGE)
@@ -670,7 +667,7 @@ version-for-buildyml:
.PHONY: Dockerfile .PHONY: Dockerfile
## Generate Dockerfile for awx image ## Generate Dockerfile for awx image
Dockerfile: tools/ansible/roles/dockerfile/templates/Dockerfile.j2 Dockerfile: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
$(ANSIBLE_PLAYBOOK) tools/ansible/dockerfile.yml \ ansible-playbook tools/ansible/dockerfile.yml \
-e receptor_image=$(RECEPTOR_IMAGE) \ -e receptor_image=$(RECEPTOR_IMAGE) \
-e headless=$(HEADLESS) -e headless=$(HEADLESS)
@@ -700,7 +697,7 @@ awx-kube-buildx: Dockerfile
.PHONY: Dockerfile.kube-dev .PHONY: Dockerfile.kube-dev
## Generate Docker.kube-dev for awx_kube_devel image ## Generate Docker.kube-dev for awx_kube_devel image
Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2 Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
$(ANSIBLE_PLAYBOOK) tools/ansible/dockerfile.yml \ ansible-playbook tools/ansible/dockerfile.yml \
-e dockerfile_name=Dockerfile.kube-dev \ -e dockerfile_name=Dockerfile.kube-dev \
-e kube_dev=True \ -e kube_dev=True \
-e template_dest=_build_kube_dev \ -e template_dest=_build_kube_dev \

View File

@@ -36,7 +36,6 @@ from ansible_base.lib.utils.models import get_all_field_names
from ansible_base.lib.utils.requests import get_remote_host from ansible_base.lib.utils.requests import get_remote_host
from ansible_base.rbac.models import RoleEvaluation, RoleDefinition from ansible_base.rbac.models import RoleEvaluation, RoleDefinition
from ansible_base.rbac.permission_registry import permission_registry from ansible_base.rbac.permission_registry import permission_registry
from ansible_base.jwt_consumer.common.util import validate_x_trusted_proxy_header
# AWX # AWX
from awx.main.models import UnifiedJob, UnifiedJobTemplate, User, Role, Credential, WorkflowJobTemplateNode, WorkflowApprovalTemplate from awx.main.models import UnifiedJob, UnifiedJobTemplate, User, Role, Credential, WorkflowJobTemplateNode, WorkflowApprovalTemplate
@@ -44,7 +43,6 @@ from awx.main.models.rbac import give_creator_permissions
from awx.main.access import optimize_queryset from awx.main.access import optimize_queryset
from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd, get_object_or_400, decrypt_field, get_awx_version from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd, get_object_or_400, decrypt_field, get_awx_version
from awx.main.utils.licensing import server_product_name from awx.main.utils.licensing import server_product_name
from awx.main.utils.proxy import is_proxy_in_headers, delete_headers_starting_with_http
from awx.main.views import ApiErrorView from awx.main.views import ApiErrorView
from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer
from awx.api.versioning import URLPathVersioning from awx.api.versioning import URLPathVersioning
@@ -155,23 +153,22 @@ class APIView(views.APIView):
Store the Django REST Framework Request object as an attribute on the Store the Django REST Framework Request object as an attribute on the
normal Django request, store time the request started. normal Django request, store time the request started.
""" """
remote_headers = ['REMOTE_ADDR', 'REMOTE_HOST']
self.time_started = time.time() self.time_started = time.time()
if getattr(settings, 'SQL_DEBUG', False): if getattr(settings, 'SQL_DEBUG', False):
self.queries_before = len(connection.queries) self.queries_before = len(connection.queries)
if 'HTTP_X_TRUSTED_PROXY' in request.environ:
if validate_x_trusted_proxy_header(request.environ['HTTP_X_TRUSTED_PROXY']):
remote_headers = settings.REMOTE_HOST_HEADERS
else:
logger.warning("Request appeared to be a trusted upstream proxy but failed to provide a matching shared secret.")
# If there are any custom headers in REMOTE_HOST_HEADERS, make sure # If there are any custom headers in REMOTE_HOST_HEADERS, make sure
# they respect the allowed proxy list # they respect the allowed proxy list
if settings.PROXY_IP_ALLOWED_LIST: if all(
if not is_proxy_in_headers(self.request, settings.PROXY_IP_ALLOWED_LIST, remote_headers): [
delete_headers_starting_with_http(request, settings.REMOTE_HOST_HEADERS) settings.PROXY_IP_ALLOWED_LIST,
request.environ.get('REMOTE_ADDR') not in settings.PROXY_IP_ALLOWED_LIST,
request.environ.get('REMOTE_HOST') not in settings.PROXY_IP_ALLOWED_LIST,
]
):
for custom_header in settings.REMOTE_HOST_HEADERS:
if custom_header.startswith('HTTP_'):
request.environ.pop(custom_header, None)
drf_request = super(APIView, self).initialize_request(request, *args, **kwargs) drf_request = super(APIView, self).initialize_request(request, *args, **kwargs)
request.drf_request = drf_request request.drf_request = drf_request

View File

@@ -61,7 +61,6 @@ import pytz
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
# django-ansible-base # django-ansible-base
from ansible_base.lib.utils.requests import get_remote_hosts
from ansible_base.rbac.models import RoleEvaluation, ObjectRole from ansible_base.rbac.models import RoleEvaluation, ObjectRole
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType
@@ -2771,7 +2770,12 @@ class JobTemplateCallback(GenericAPIView):
host for the current request. host for the current request.
""" """
# Find the list of remote host names/IPs to check. # Find the list of remote host names/IPs to check.
remote_hosts = set(get_remote_hosts(self.request)) remote_hosts = set()
for header in settings.REMOTE_HOST_HEADERS:
for value in self.request.META.get(header, '').split(','):
value = value.strip()
if value:
remote_hosts.add(value)
# Add the reverse lookup of IP addresses. # Add the reverse lookup of IP addresses.
for rh in list(remote_hosts): for rh in list(remote_hosts):
try: try:

View File

@@ -598,7 +598,7 @@ class InstanceGroupAccess(BaseAccess):
- a superuser - a superuser
- admin role on the Instance group - admin role on the Instance group
I can add/delete Instance Groups: I can add/delete Instance Groups:
- a superuser(system administrator), because these are not org-scoped - a superuser(system administrator)
I can use Instance Groups when I have: I can use Instance Groups when I have:
- use_role on the instance group - use_role on the instance group
""" """
@@ -627,7 +627,7 @@ class InstanceGroupAccess(BaseAccess):
def can_delete(self, obj): def can_delete(self, obj):
if obj.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]: if obj.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
return False return False
return self.user.has_obj_perm(obj, 'delete') return self.user.is_superuser
class UserAccess(BaseAccess): class UserAccess(BaseAccess):
@@ -2628,7 +2628,7 @@ class ScheduleAccess(UnifiedCredentialsMixin, BaseAccess):
class NotificationTemplateAccess(BaseAccess): class NotificationTemplateAccess(BaseAccess):
""" """
Run standard logic from DAB RBAC I can see/use a notification_template if I have permission to
""" """
model = NotificationTemplate model = NotificationTemplate
@@ -2649,7 +2649,10 @@ class NotificationTemplateAccess(BaseAccess):
@check_superuser @check_superuser
def can_change(self, obj, data): def can_change(self, obj, data):
return self.user.has_obj_perm(obj, 'change') and self.check_related('organization', Organization, data, obj=obj, role_field='notification_admin_role') if obj.organization is None:
# only superusers are allowed to edit orphan notification templates
return False
return self.check_related('organization', Organization, data, obj=obj, role_field='notification_admin_role', mandatory=True)
def can_admin(self, obj, data): def can_admin(self, obj, data):
return self.can_change(obj, data) return self.can_change(obj, data)
@@ -2659,7 +2662,9 @@ class NotificationTemplateAccess(BaseAccess):
@check_superuser @check_superuser
def can_start(self, obj, validate_license=True): def can_start(self, obj, validate_license=True):
return self.can_change(obj, None) if obj.organization is None:
return False
return self.user in obj.organization.notification_admin_role
class NotificationAccess(BaseAccess): class NotificationAccess(BaseAccess):

View File

@@ -290,7 +290,7 @@ def setup_managed_role_definitions(apps, schema_editor):
managed_role_definitions = [] managed_role_definitions = []
org_perms = set() org_perms = set()
for cls in permission_registry.all_registered_models: for cls in permission_registry._registry:
ct = ContentType.objects.get_for_model(cls) ct = ContentType.objects.get_for_model(cls)
object_perms = set(Permission.objects.filter(content_type=ct)) object_perms = set(Permission.objects.filter(content_type=ct))
# Special case for InstanceGroup which has an organiation field, but is not an organization child object # Special case for InstanceGroup which has an organiation field, but is not an organization child object

View File

@@ -1,30 +1,22 @@
import pytest import pytest
from unittest import mock
from awx.api.versioning import reverse from awx.api.versioning import reverse
from django.test.utils import override_settings
from ansible_base.jwt_consumer.common.util import generate_x_trusted_proxy_header
from ansible_base.lib.testing.fixtures import rsa_keypair_factory, rsa_keypair # noqa: F401; pylint: disable=unused-import
class HeaderTrackingMiddleware(object):
def __init__(self):
self.environ = {}
def process_request(self, request):
pass
def process_response(self, request, response):
self.environ = request.environ
@pytest.mark.django_db @pytest.mark.django_db
def test_proxy_ip_allowed(get, patch, admin): def test_proxy_ip_allowed(get, patch, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'system'}) url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'system'})
patch(url, user=admin, data={'REMOTE_HOST_HEADERS': ['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST']}) patch(url, user=admin, data={'REMOTE_HOST_HEADERS': ['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST']})
class HeaderTrackingMiddleware(object):
environ = {}
def process_request(self, request):
pass
def process_response(self, request, response):
self.environ = request.environ
# By default, `PROXY_IP_ALLOWED_LIST` is disabled, so custom `REMOTE_HOST_HEADERS` # By default, `PROXY_IP_ALLOWED_LIST` is disabled, so custom `REMOTE_HOST_HEADERS`
# should just pass through # should just pass through
middleware = HeaderTrackingMiddleware() middleware = HeaderTrackingMiddleware()
@@ -53,51 +45,6 @@ def test_proxy_ip_allowed(get, patch, admin):
assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip' assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip'
@pytest.mark.django_db
class TestTrustedProxyAllowListIntegration:
@pytest.fixture
def url(self, patch, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'system'})
patch(url, user=admin, data={'REMOTE_HOST_HEADERS': ['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST']})
patch(url, user=admin, data={'PROXY_IP_ALLOWED_LIST': ['my.proxy.example.org']})
return url
@pytest.fixture
def middleware(self):
return HeaderTrackingMiddleware()
def test_x_trusted_proxy_valid_signature(self, get, admin, rsa_keypair, url, middleware): # noqa: F811
# Headers should NOT get deleted
headers = {
'HTTP_X_TRUSTED_PROXY': generate_x_trusted_proxy_header(rsa_keypair.private),
'HTTP_X_FROM_THE_LOAD_BALANCER': 'some-actual-ip',
}
with mock.patch('ansible_base.jwt_consumer.common.cache.JWTCache.get_key_from_cache', lambda self: None):
with override_settings(ANSIBLE_BASE_JWT_KEY=rsa_keypair.public, PROXY_IP_ALLOWED_LIST=[]):
get(url, user=admin, middleware=middleware, **headers)
assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip'
def test_x_trusted_proxy_invalid_signature(self, get, admin, url, patch, middleware):
# Headers should NOT get deleted
headers = {
'HTTP_X_TRUSTED_PROXY': 'DEAD-BEEF',
'HTTP_X_FROM_THE_LOAD_BALANCER': 'some-actual-ip',
}
with override_settings(PROXY_IP_ALLOWED_LIST=[]):
get(url, user=admin, middleware=middleware, **headers)
assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip'
def test_x_trusted_proxy_invalid_signature_valid_proxy(self, get, admin, url, middleware):
# A valid explicit proxy SHOULD result in sensitive headers NOT being deleted, regardless of the trusted proxy signature results
headers = {
'HTTP_X_TRUSTED_PROXY': 'DEAD-BEEF',
'REMOTE_ADDR': 'my.proxy.example.org',
'HTTP_X_FROM_THE_LOAD_BALANCER': 'some-actual-ip',
}
get(url, user=admin, middleware=middleware, **headers)
assert middleware.environ['HTTP_X_FROM_THE_LOAD_BALANCER'] == 'some-actual-ip'
@pytest.mark.django_db @pytest.mark.django_db
class TestDeleteViews: class TestDeleteViews:
def test_sublist_delete_permission_check(self, inventory_source, host, rando, delete): def test_sublist_delete_permission_check(self, inventory_source, host, rando, delete):

View File

@@ -32,6 +32,13 @@ def node_type_instance():
return fn return fn
@pytest.fixture
def instance_group(job_factory):
ig = InstanceGroup(name="east")
ig.save()
return ig
@pytest.fixture @pytest.fixture
def containerized_instance_group(instance_group, kube_credential): def containerized_instance_group(instance_group, kube_credential):
ig = InstanceGroup(name="container") ig = InstanceGroup(name="container")

View File

@@ -1,5 +1,4 @@
import pytest import pytest
from unittest import mock
# AWX # AWX
from awx.api.serializers import JobTemplateSerializer from awx.api.serializers import JobTemplateSerializer
@@ -9,15 +8,10 @@ from awx.main.migrations import _save_password_keys as save_password_keys
# Django # Django
from django.apps import apps from django.apps import apps
from django.test.utils import override_settings
# DRF # DRF
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
# DAB
from ansible_base.jwt_consumer.common.util import generate_x_trusted_proxy_header
from ansible_base.lib.testing.fixtures import rsa_keypair_factory, rsa_keypair # noqa: F401; pylint: disable=unused-import
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -375,113 +369,3 @@ def test_job_template_missing_inventory(project, inventory, admin_user, post):
) )
assert r.status_code == 400 assert r.status_code == 400
assert "Cannot start automatically, an inventory is required." in str(r.data) assert "Cannot start automatically, an inventory is required." in str(r.data)
@pytest.mark.django_db
class TestJobTemplateCallbackProxyIntegration:
"""
Test the interaction of provision job template callback feature and:
settings.PROXY_IP_ALLOWED_LIST
x-trusted-proxy http header
"""
@pytest.fixture
def job_template(self, inventory, project):
jt = JobTemplate.objects.create(name='test-jt', inventory=inventory, project=project, playbook='helloworld.yml', host_config_key='abcd')
return jt
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=['my.proxy.example.org'])
def test_host_not_found(self, job_template, admin_user, post, rsa_keypair): # noqa: F811
job_template.inventory.hosts.create(name='foobar')
headers = {
'HTTP_X_FROM_THE_LOAD_BALANCER': 'baz',
'REMOTE_HOST': 'baz',
'REMOTE_ADDR': 'baz',
}
r = post(
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=400, **headers
)
assert r.data['msg'] == 'No matching host could be found!'
@pytest.mark.parametrize(
'headers, expected',
(
pytest.param(
{
'HTTP_X_FROM_THE_LOAD_BALANCER': 'foobar',
'REMOTE_HOST': 'my.proxy.example.org',
},
201,
),
pytest.param(
{
'HTTP_X_FROM_THE_LOAD_BALANCER': 'foobar',
'REMOTE_HOST': 'not-my-proxy.org',
},
400,
),
),
)
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=['my.proxy.example.org'])
def test_proxy_ip_allowed_list(self, job_template, admin_user, post, headers, expected): # noqa: F811
job_template.inventory.hosts.create(name='my.proxy.example.org')
post(
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}),
data={'host_config_key': 'abcd'},
user=admin_user,
expect=expected,
**headers
)
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=[])
def test_no_proxy_trust_all_headers(self, job_template, admin_user, post):
job_template.inventory.hosts.create(name='foobar')
headers = {
'HTTP_X_FROM_THE_LOAD_BALANCER': 'foobar',
'REMOTE_ADDR': 'bar',
'REMOTE_HOST': 'baz',
}
post(url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=201, **headers)
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=['my.proxy.example.org'])
def test_trusted_proxy(self, job_template, admin_user, post, rsa_keypair): # noqa: F811
job_template.inventory.hosts.create(name='foobar')
headers = {
'HTTP_X_TRUSTED_PROXY': generate_x_trusted_proxy_header(rsa_keypair.private),
'HTTP_X_FROM_THE_LOAD_BALANCER': 'foobar, my.proxy.example.org',
}
with mock.patch('ansible_base.jwt_consumer.common.cache.JWTCache.get_key_from_cache', lambda self: None):
with override_settings(ANSIBLE_BASE_JWT_KEY=rsa_keypair.public):
post(
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}),
data={'host_config_key': 'abcd'},
user=admin_user,
expect=201,
**headers
)
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=['my.proxy.example.org'])
def test_trusted_proxy_host_not_found(self, job_template, admin_user, post, rsa_keypair): # noqa: F811
job_template.inventory.hosts.create(name='foobar')
headers = {
'HTTP_X_TRUSTED_PROXY': generate_x_trusted_proxy_header(rsa_keypair.private),
'HTTP_X_FROM_THE_LOAD_BALANCER': 'baz, my.proxy.example.org',
'REMOTE_ADDR': 'bar',
'REMOTE_HOST': 'baz',
}
with mock.patch('ansible_base.jwt_consumer.common.cache.JWTCache.get_key_from_cache', lambda self: None):
with override_settings(ANSIBLE_BASE_JWT_KEY=rsa_keypair.public):
post(
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}),
data={'host_config_key': 'abcd'},
user=admin_user,
expect=400,
**headers
)

View File

@@ -20,7 +20,7 @@ from awx.main.migrations._dab_rbac import setup_managed_role_definitions
# AWX # AWX
from awx.main.models.projects import Project from awx.main.models.projects import Project
from awx.main.models.ha import Instance, InstanceGroup from awx.main.models.ha import Instance
from rest_framework.test import ( from rest_framework.test import (
APIRequestFactory, APIRequestFactory,
@@ -730,11 +730,6 @@ def jt_linked(organization, project, inventory, machine_credential, credential,
return jt return jt
@pytest.fixture
def instance_group():
return InstanceGroup.objects.create(name="east")
@pytest.fixture @pytest.fixture
def workflow_job_template(organization): def workflow_job_template(organization):
wjt = WorkflowJobTemplate.objects.create(name='test-workflow_job_template', organization=organization) wjt = WorkflowJobTemplate.objects.create(name='test-workflow_job_template', organization=organization)

View File

@@ -1,23 +0,0 @@
import pytest
from awx.main.access import InstanceGroupAccess, NotificationTemplateAccess
from ansible_base.rbac.models import RoleDefinition
@pytest.mark.django_db
def test_instance_group_object_role_delete(rando, instance_group, setup_managed_roles):
"""Basic functionality of IG object-level admin role function AAP-25506"""
rd = RoleDefinition.objects.get(name='InstanceGroup Admin')
rd.give_permission(rando, instance_group)
access = InstanceGroupAccess(rando)
assert access.can_delete(instance_group)
@pytest.mark.django_db
def test_notification_template_object_role_change(rando, notification_template, setup_managed_roles):
"""Basic functionality of NT object-level admin role function AAP-25493"""
rd = RoleDefinition.objects.get(name='NotificationTemplate Admin')
rd.give_permission(rando, notification_template)
access = NotificationTemplateAccess(rando)
assert access.can_change(notification_template, {'name': 'new name'})

View File

@@ -99,9 +99,7 @@ def test_notification_template_access_org_user(notification_template, user):
@pytest.mark.django_db @pytest.mark.django_db
def test_notificaiton_template_orphan_access_org_admin(notification_template, organization, org_admin): def test_notificaiton_template_orphan_access_org_admin(notification_template, organization, org_admin):
notification_template.organization = None notification_template.organization = None
notification_template.save(update_fields=['organization'])
access = NotificationTemplateAccess(org_admin) access = NotificationTemplateAccess(org_admin)
assert not org_admin.has_obj_perm(notification_template, 'change')
assert not access.can_change(notification_template, {'organization': organization.id}) assert not access.can_change(notification_template, {'organization': organization.id})

View File

@@ -1,48 +0,0 @@
# Copyright (c) 2024 Ansible, Inc.
# All Rights Reserved.
# DRF
from rest_framework.request import Request
"""
Note that these methods operate on request.environ. This data is from uwsgi.
It is the source data from which request.headers (read-only) is constructed.
"""
def is_proxy_in_headers(request: Request, proxy_list: list[str], headers: list[str]) -> bool:
"""
Determine if the request went through at least one proxy in the list.
Example:
request.environ = {
"HTTP_X_FOO": "8.8.8.8, 192.168.2.1",
"REMOTE_ADDR": "192.168.2.1",
"REMOTE_HOST": "foobar"
}
proxy_list = ["192.168.2.1"]
headers = ["HTTP_X_FOO", "REMOTE_ADDR", "REMOTE_HOST"]
The above would return True since 192.168.2.1 is a value for the header HTTP_X_FOO
request: The DRF/Django request. request.environ dict will be used for searching for proxies
proxy_list: A list of known and trusted proxies may be ip or hostnames
headers: A list of keys for which to consider values that may contain a proxy
"""
remote_hosts = set()
for header in headers:
for value in request.environ.get(header, '').split(','):
value = value.strip()
if value:
remote_hosts.add(value)
return bool(remote_hosts.intersection(set(proxy_list)))
def delete_headers_starting_with_http(request: Request, headers: list[str]):
for header in headers:
if header.startswith('HTTP_'):
request.environ.pop(header, None)

View File

@@ -0,0 +1,48 @@
import logging
# from awxkit.api.mixins import DSAdapter, HasCreate, HasCopy
# from awxkit.api.pages import (
# Credential,
# Organization,
# )
from awxkit.api.resources import resources
# from awxkit.utils import random_title, PseudoNamespace, filter_by_class
from . import base
from . import page
log = logging.getLogger(__name__)
class RoleTeamAssignment(base.Base):
NATURAL_KEY = ('team', 'content_object', 'role_definition')
page.register_page(
[resources.role_team_assignment, (resources.role_definition_team_assignments, 'post'), (resources.role_team_assignments, 'post')], RoleTeamAssignment
)
class RoleUserAssignment(base.Base):
NATURAL_KEY = ('user', 'content_object', 'role_definition')
page.register_page(
[resources.role_user_assignment, (resources.role_definition_user_assignments, 'post'), (resources.role_user_assignments, 'post')], RoleUserAssignment
)
class RoleTeamAssignments(page.PageList, RoleTeamAssignment):
pass
page.register_page([resources.role_definition_team_assignments, resources.role_team_assignments], RoleTeamAssignments)
class RoleUserAssignments(page.PageList, RoleUserAssignment):
pass
page.register_page([resources.role_definition_user_assignments, resources.role_user_assignments], RoleUserAssignments)

View File

@@ -0,0 +1,30 @@
import logging
# from awxkit.api.mixins import DSAdapter, HasCreate, HasCopy
# from awxkit.api.pages import (
# Credential,
# Organization,
# )
from awxkit.api.resources import resources
# from awxkit.utils import random_title, PseudoNamespace, filter_by_class
from . import base
from . import page
log = logging.getLogger(__name__)
class RoleDefinition(base.Base):
NATURAL_KEY = ('name',)
page.register_page([resources.role_definition, (resources.role_definitions, 'post')], RoleDefinition)
class RoleDefinitions(page.PageList, RoleDefinition):
pass
page.register_page([resources.role_definitions], RoleDefinitions)

View File

@@ -197,6 +197,14 @@ class Resources(object):
_related_users = r'\w+/\d+/users/' _related_users = r'\w+/\d+/users/'
_related_workflow_job_templates = r'\w+/\d+/workflow_job_templates/' _related_workflow_job_templates = r'\w+/\d+/workflow_job_templates/'
_role = r'roles/\d+/' _role = r'roles/\d+/'
_role_definition = r'role_definitions/\d+/'
_role_definitions = r'role_definitions/'
_role_definition_team_assignments = r'role_definitions/\d+/team_assignments/'
_role_definition_user_assignments = r'role_definitions/\d+/user_assignments/'
_role_team_assignment = r'role_team_assignments/\d+/'
_role_team_assignments = r'role_team_assignments/'
_role_user_assignment = r'role_user_assignments/\d+/'
_role_user_assignments = r'role_user_assignments/'
_roles = 'roles/' _roles = 'roles/'
_roles_related_teams = r'roles/\d+/teams/' _roles_related_teams = r'roles/\d+/teams/'
_schedule = r'schedules/\d+/' _schedule = r'schedules/\d+/'

View File

@@ -22,9 +22,7 @@ def resolve(obj, path):
if new_obj is None: if new_obj is None:
return set() return set()
if not path: if not path:
return { return {new_obj,}
new_obj,
}
if isinstance(new_obj, ManyToManyDescriptor): if isinstance(new_obj, ManyToManyDescriptor):
return {x for o in new_obj.all() for x in resolve(o, path)} return {x for o in new_obj.all() for x in resolve(o, path)}
@@ -55,9 +53,7 @@ for ct in ContentType.objects.order_by('id'):
crosslinked[ct.id][obj.id][f'{f.name}_id'] = None crosslinked[ct.id][obj.id][f'{f.name}_id'] = None
continue continue
if r.content_object != obj: if r.content_object != obj:
sys.stderr.write( sys.stderr.write(f"{cls.__name__} id={obj.id} {f.name} is pointing to a Role that is assigned to a different object: role.id={r.id} {r.content_type!r} {r.object_id} {r.role_field}\n")
f"{cls.__name__} id={obj.id} {f.name} is pointing to a Role that is assigned to a different object: role.id={r.id} {r.content_type!r} {r.object_id} {r.role_field}\n"
)
crosslinked[ct.id][obj.id][f'{f.name}_id'] = None crosslinked[ct.id][obj.id][f'{f.name}_id'] = None
continue continue
@@ -91,23 +87,16 @@ for r in Role.objects.exclude(role_field__startswith='system_').order_by('id'):
# Check the resource's role field parents for consistency with Role.parents.all(). # Check the resource's role field parents for consistency with Role.parents.all().
f = r.content_object._meta.get_field(r.role_field) f = r.content_object._meta.get_field(r.role_field)
f_parent = ( f_parent = set(f.parent_role) if isinstance(f.parent_role, list) else {f.parent_role,}
set(f.parent_role)
if isinstance(f.parent_role, list)
else {
f.parent_role,
}
)
dotted = {x for p in f_parent if '.' in p for x in resolve(r.content_object, p)} dotted = {x for p in f_parent if '.' in p for x in resolve(r.content_object, p)}
plus = set() plus = set()
for p in r.parents.all(): for p in r.parents.all():
if p.singleton_name: if p.singleton_name:
if f'singleton:{p.singleton_name}' not in f_parent: if f'singleton:{p.singleton_name}' not in f_parent:
plus.add(p) plus.add(p)
elif p.content_type == team_ct: elif (p.content_type, p.role_field) == (team_ct, 'member_role'):
# Team has been granted this role; probably legitimate. # Team has been granted this role; probably legitimate.
if p.role_field in ('admin_role', 'member_role'): continue
continue
elif (p.content_type, p.object_id) == (r.content_type, r.object_id): elif (p.content_type, p.object_id) == (r.content_type, r.object_id):
if p.role_field not in f_parent: if p.role_field not in f_parent:
plus.add(p) plus.add(p)
@@ -129,17 +118,13 @@ for r in Role.objects.exclude(role_field__startswith='system_').order_by('id'):
continue continue
if rev is None or r.id != rev.id: if rev is None or r.id != rev.id:
if rev and (r.content_type_id, r.object_id, r.role_field) == (rev.content_type_id, rev.object_id, rev.role_field): if rev and (r.content_type_id, r.object_id, r.role_field) == (rev.content_type_id, rev.object_id, rev.role_field):
sys.stderr.write( sys.stderr.write(f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is an orphaned duplicate of Role id={rev.id}, which is actually being used by the assigned resource\n")
f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is an orphaned duplicate of Role id={rev.id}, which is actually being used by the assigned resource\n"
)
orphaned_roles.add(r.id) orphaned_roles.add(r.id)
elif not rev: elif not rev:
sys.stderr.write(f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is pointing to an object currently using no role\n") sys.stderr.write(f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is pointing to an object currently using no role\n")
crosslinked[r.content_type_id][r.object_id][f'{r.role_field}_id'] = r.id crosslinked[r.content_type_id][r.object_id][f'{r.role_field}_id'] = r.id
else: else:
sys.stderr.write( sys.stderr.write(f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is pointing to an object using a different role: id={rev.id} {rev.content_type!r} {rev.object_id} {rev.role_field}\n")
f"Role id={r.id} {r.content_type!r} {r.object_id} {r.role_field} is pointing to an object using a different role: id={rev.id} {rev.content_type!r} {rev.object_id} {rev.role_field}\n"
)
crosslinked[r.content_type_id][r.object_id][f'{r.role_field}_id'] = r.id crosslinked[r.content_type_id][r.object_id][f'{r.role_field}_id'] = r.id
continue continue
@@ -147,8 +132,7 @@ for r in Role.objects.exclude(role_field__startswith='system_').order_by('id'):
sys.stderr.write('===================================\n') sys.stderr.write('===================================\n')
print( print(f"""\
f"""\
from collections import Counter from collections import Counter
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@@ -160,8 +144,7 @@ from awx.main.models.rbac import Role
delete_counts = Counter() delete_counts = Counter()
update_counts = Counter() update_counts = Counter()
""" """)
)
print("# Resource objects that are pointing to the wrong Role. Some of these") print("# Resource objects that are pointing to the wrong Role. Some of these")

View File

@@ -21,9 +21,7 @@ with connection.cursor() as cursor:
cursor.execute("UPDATE main_instancegroup SET use_role_id = NULL WHERE name = 'red'") cursor.execute("UPDATE main_instancegroup SET use_role_id = NULL WHERE name = 'red'")
cursor.execute(f"UPDATE main_instancegroup SET use_role_id = {green.use_role_id} WHERE name = 'yellow'") cursor.execute(f"UPDATE main_instancegroup SET use_role_id = {green.use_role_id} WHERE name = 'yellow'")
cursor.execute( cursor.execute("ALTER TABLE main_instancegroup ADD CONSTRAINT main_instancegroup_use_role_id_48ea7ecc_fk_main_rbac_roles_id FOREIGN KEY (use_role_id) REFERENCES public.main_rbac_roles(id) DEFERRABLE INITIALLY DEFERRED NOT VALID")
"ALTER TABLE main_instancegroup ADD CONSTRAINT main_instancegroup_use_role_id_48ea7ecc_fk_main_rbac_roles_id FOREIGN KEY (use_role_id) REFERENCES public.main_rbac_roles(id) DEFERRABLE INITIALLY DEFERRED NOT VALID"
)
print("=====================================") print("=====================================")
for ig in InstanceGroup.objects.all(): for ig in InstanceGroup.objects.all():