mirror of
https://github.com/ansible/awx.git
synced 2026-03-01 08:48:46 -03:30
Prevent modifying shared resources when using platform ingress (#15234)
* Prevent modifying shared resources Adds a class decorator to prevent modifying shared resources when gateway is being used. AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED is the setting to enable/disable this feature. Works by overriding these view methods: - create - delete - perform_update create and delete are overridden to raise a PermissionDenied exception. perform_update is overridden to check if any shared fields are being modified, and raise a PermissionDenied exception if so. Additional changes: Prevent sso conf from registering external authentication related settings if AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED is False Signed-off-by: Seth Foster <fosterbseth@gmail.com> Co-authored-by: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com>
This commit is contained in:
@@ -62,6 +62,7 @@ from wsgiref.util import FileWrapper
|
|||||||
|
|
||||||
# django-ansible-base
|
# django-ansible-base
|
||||||
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
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
|
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
|
||||||
@@ -128,6 +129,7 @@ from awx.api.views.mixin import (
|
|||||||
from awx.api.pagination import UnifiedJobEventPagination
|
from awx.api.pagination import UnifiedJobEventPagination
|
||||||
from awx.main.utils import set_environ
|
from awx.main.utils import set_environ
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.api.views')
|
logger = logging.getLogger('awx.api.views')
|
||||||
|
|
||||||
|
|
||||||
@@ -710,16 +712,81 @@ class AuthView(APIView):
|
|||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def immutablesharedfields(cls):
|
||||||
|
'''
|
||||||
|
Class decorator to prevent modifying shared resources when AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED setting is set to False.
|
||||||
|
|
||||||
|
Works by overriding these view methods:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- perform_update
|
||||||
|
create and delete are overridden to raise a PermissionDenied exception.
|
||||||
|
perform_update is overridden to check if any shared fields are being modified,
|
||||||
|
and raise a PermissionDenied exception if so.
|
||||||
|
'''
|
||||||
|
# create instead of perform_create because some of our views
|
||||||
|
# override create instead of perform_create
|
||||||
|
if hasattr(cls, 'create'):
|
||||||
|
cls.original_create = cls.create
|
||||||
|
|
||||||
|
@functools.wraps(cls.create)
|
||||||
|
def create_wrapper(*args, **kwargs):
|
||||||
|
if settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED:
|
||||||
|
return cls.original_create(*args, **kwargs)
|
||||||
|
raise PermissionDenied({'detail': _('Creation of this resource is not allowed. Create this resource via the platform ingress.')})
|
||||||
|
|
||||||
|
cls.create = create_wrapper
|
||||||
|
|
||||||
|
if hasattr(cls, 'delete'):
|
||||||
|
cls.original_delete = cls.delete
|
||||||
|
|
||||||
|
@functools.wraps(cls.delete)
|
||||||
|
def delete_wrapper(*args, **kwargs):
|
||||||
|
if settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED:
|
||||||
|
return cls.original_delete(*args, **kwargs)
|
||||||
|
raise PermissionDenied({'detail': _('Deletion of this resource is not allowed. Delete this resource via the platform ingress.')})
|
||||||
|
|
||||||
|
cls.delete = delete_wrapper
|
||||||
|
|
||||||
|
if hasattr(cls, 'perform_update'):
|
||||||
|
cls.original_perform_update = cls.perform_update
|
||||||
|
|
||||||
|
@functools.wraps(cls.perform_update)
|
||||||
|
def update_wrapper(*args, **kwargs):
|
||||||
|
if not settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED:
|
||||||
|
view, serializer = args
|
||||||
|
instance = view.get_object()
|
||||||
|
if instance:
|
||||||
|
if isinstance(instance, models.Organization):
|
||||||
|
shared_fields = OrganizationType._declared_fields.keys()
|
||||||
|
elif isinstance(instance, models.User):
|
||||||
|
shared_fields = UserType._declared_fields.keys()
|
||||||
|
elif isinstance(instance, models.Team):
|
||||||
|
shared_fields = TeamType._declared_fields.keys()
|
||||||
|
attrs = serializer.validated_data
|
||||||
|
for field in shared_fields:
|
||||||
|
if field in attrs and getattr(instance, field) != attrs[field]:
|
||||||
|
raise PermissionDenied({field: _(f"Cannot change shared field '{field}'. Alter this field via the platform ingress.")})
|
||||||
|
return cls.original_perform_update(*args, **kwargs)
|
||||||
|
|
||||||
|
cls.perform_update = update_wrapper
|
||||||
|
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class TeamList(ListCreateAPIView):
|
class TeamList(ListCreateAPIView):
|
||||||
model = models.Team
|
model = models.Team
|
||||||
serializer_class = serializers.TeamSerializer
|
serializer_class = serializers.TeamSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class TeamDetail(RetrieveUpdateDestroyAPIView):
|
class TeamDetail(RetrieveUpdateDestroyAPIView):
|
||||||
model = models.Team
|
model = models.Team
|
||||||
serializer_class = serializers.TeamSerializer
|
serializer_class = serializers.TeamSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class TeamUsersList(BaseUsersList):
|
class TeamUsersList(BaseUsersList):
|
||||||
model = models.User
|
model = models.User
|
||||||
serializer_class = serializers.UserSerializer
|
serializer_class = serializers.UserSerializer
|
||||||
@@ -1101,6 +1168,7 @@ class ProjectCopy(CopyAPIView):
|
|||||||
copy_return_serializer_class = serializers.ProjectSerializer
|
copy_return_serializer_class = serializers.ProjectSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class UserList(ListCreateAPIView):
|
class UserList(ListCreateAPIView):
|
||||||
model = models.User
|
model = models.User
|
||||||
serializer_class = serializers.UserSerializer
|
serializer_class = serializers.UserSerializer
|
||||||
@@ -1271,7 +1339,16 @@ class UserRolesList(SubListAttachDetachAPIView):
|
|||||||
user = get_object_or_400(models.User, pk=self.kwargs['pk'])
|
user = get_object_or_400(models.User, pk=self.kwargs['pk'])
|
||||||
role = get_object_or_400(models.Role, pk=sub_id)
|
role = get_object_or_400(models.Role, pk=sub_id)
|
||||||
|
|
||||||
credential_content_type = ContentType.objects.get_for_model(models.Credential)
|
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||||
|
# Prevent user to be associated with team/org when AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED is False
|
||||||
|
if not settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED:
|
||||||
|
for model in [models.Organization, models.Team]:
|
||||||
|
ct = content_types[model]
|
||||||
|
if role.content_type == ct and role.role_field in ['member_role', 'admin_role']:
|
||||||
|
data = dict(msg=_(f"Cannot directly modify user membership to {ct.model}. Direct shared resource management disabled"))
|
||||||
|
return Response(data, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
credential_content_type = content_types[models.Credential]
|
||||||
if role.content_type == credential_content_type:
|
if role.content_type == credential_content_type:
|
||||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||||
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
||||||
@@ -1343,6 +1420,7 @@ class UserActivityStreamList(SubListAPIView):
|
|||||||
return qs.filter(Q(actor=parent) | Q(user__in=[parent]))
|
return qs.filter(Q(actor=parent) | Q(user__in=[parent]))
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class UserDetail(RetrieveUpdateDestroyAPIView):
|
class UserDetail(RetrieveUpdateDestroyAPIView):
|
||||||
model = models.User
|
model = models.User
|
||||||
serializer_class = serializers.UserSerializer
|
serializer_class = serializers.UserSerializer
|
||||||
@@ -4295,7 +4373,15 @@ class RoleUsersList(SubListAttachDetachAPIView):
|
|||||||
user = get_object_or_400(models.User, pk=sub_id)
|
user = get_object_or_400(models.User, pk=sub_id)
|
||||||
role = self.get_parent_object()
|
role = self.get_parent_object()
|
||||||
|
|
||||||
credential_content_type = ContentType.objects.get_for_model(models.Credential)
|
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||||
|
if not settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED:
|
||||||
|
for model in [models.Organization, models.Team]:
|
||||||
|
ct = content_types[model]
|
||||||
|
if role.content_type == ct and role.role_field in ['member_role', 'admin_role']:
|
||||||
|
data = dict(msg=_(f"Cannot directly modify user membership to {ct.model}. Direct shared resource management disabled"))
|
||||||
|
return Response(data, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
credential_content_type = content_types[models.Credential]
|
||||||
if role.content_type == credential_content_type:
|
if role.content_type == credential_content_type:
|
||||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||||
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
||||||
|
|||||||
@@ -53,15 +53,18 @@ from awx.api.serializers import (
|
|||||||
CredentialSerializer,
|
CredentialSerializer,
|
||||||
)
|
)
|
||||||
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin
|
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin
|
||||||
|
from awx.api.views import immutablesharedfields
|
||||||
|
|
||||||
logger = logging.getLogger('awx.api.views.organization')
|
logger = logging.getLogger('awx.api.views.organization')
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
|
class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
|
||||||
model = Organization
|
model = Organization
|
||||||
serializer_class = OrganizationSerializer
|
serializer_class = OrganizationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
model = Organization
|
model = Organization
|
||||||
serializer_class = OrganizationSerializer
|
serializer_class = OrganizationSerializer
|
||||||
@@ -104,6 +107,7 @@ class OrganizationInventoriesList(SubListAPIView):
|
|||||||
relationship = 'inventories'
|
relationship = 'inventories'
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class OrganizationUsersList(BaseUsersList):
|
class OrganizationUsersList(BaseUsersList):
|
||||||
model = User
|
model = User
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
@@ -112,6 +116,7 @@ class OrganizationUsersList(BaseUsersList):
|
|||||||
ordering = ('username',)
|
ordering = ('username',)
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class OrganizationAdminsList(BaseUsersList):
|
class OrganizationAdminsList(BaseUsersList):
|
||||||
model = User
|
model = User
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
@@ -150,6 +155,7 @@ class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
|
|||||||
parent_key = 'organization'
|
parent_key = 'organization'
|
||||||
|
|
||||||
|
|
||||||
|
@immutablesharedfields
|
||||||
class OrganizationTeamsList(SubListCreateAttachDetachAPIView):
|
class OrganizationTeamsList(SubListCreateAttachDetachAPIView):
|
||||||
model = Team
|
model = Team
|
||||||
serializer_class = TeamSerializer
|
serializer_class = TeamSerializer
|
||||||
|
|||||||
66
awx/main/tests/functional/api/test_immutablesharedfields.py
Normal file
66
awx/main/tests/functional/api/test_immutablesharedfields.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from awx.api.versioning import reverse
|
||||||
|
from awx.main.models import Organization
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestImmutableSharedFields:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def configure_settings(self, settings):
|
||||||
|
settings.AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED = False
|
||||||
|
|
||||||
|
def test_create_raises_permission_denied(self, admin_user, post):
|
||||||
|
orgA = Organization.objects.create(name='orgA')
|
||||||
|
resp = post(
|
||||||
|
url=reverse('api:team_list'),
|
||||||
|
data={'name': 'teamA', 'organization': orgA.id},
|
||||||
|
user=admin_user,
|
||||||
|
expect=403,
|
||||||
|
)
|
||||||
|
assert "Creation of this resource is not allowed" in resp.data['detail']
|
||||||
|
|
||||||
|
def test_perform_delete_raises_permission_denied(self, admin_user, delete):
|
||||||
|
orgA = Organization.objects.create(name='orgA')
|
||||||
|
team = orgA.teams.create(name='teamA')
|
||||||
|
resp = delete(
|
||||||
|
url=reverse('api:team_detail', kwargs={'pk': team.id}),
|
||||||
|
user=admin_user,
|
||||||
|
expect=403,
|
||||||
|
)
|
||||||
|
assert "Deletion of this resource is not allowed" in resp.data['detail']
|
||||||
|
|
||||||
|
def test_perform_update(self, admin_user, patch):
|
||||||
|
orgA = Organization.objects.create(name='orgA')
|
||||||
|
team = orgA.teams.create(name='teamA')
|
||||||
|
# allow patching non-shared fields
|
||||||
|
patch(
|
||||||
|
url=reverse('api:team_detail', kwargs={'pk': team.id}),
|
||||||
|
data={"description": "can change this field"},
|
||||||
|
user=admin_user,
|
||||||
|
expect=200,
|
||||||
|
)
|
||||||
|
orgB = Organization.objects.create(name='orgB')
|
||||||
|
# prevent patching shared fields
|
||||||
|
resp = patch(url=reverse('api:team_detail', kwargs={'pk': team.id}), data={"organization": orgB.id}, user=admin_user, expect=403)
|
||||||
|
assert "Cannot change shared field" in resp.data['organization']
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'role',
|
||||||
|
['admin_role', 'member_role'],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize('resource', ['organization', 'team'])
|
||||||
|
def test_prevent_assigning_member_to_organization_or_team(self, admin_user, post, resource, role):
|
||||||
|
orgA = Organization.objects.create(name='orgA')
|
||||||
|
if resource == 'organization':
|
||||||
|
role = getattr(orgA, role)
|
||||||
|
elif resource == 'team':
|
||||||
|
teamA = orgA.teams.create(name='teamA')
|
||||||
|
role = getattr(teamA, role)
|
||||||
|
resp = post(
|
||||||
|
url=reverse('api:user_roles_list', kwargs={'pk': admin_user.id}),
|
||||||
|
data={'id': role.id},
|
||||||
|
user=admin_user,
|
||||||
|
expect=403,
|
||||||
|
)
|
||||||
|
assert f"Cannot directly modify user membership to {resource}." in resp.data['msg']
|
||||||
@@ -656,6 +656,10 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = ""
|
|||||||
# Automatically remove nodes that have missed their heartbeats after some time
|
# Automatically remove nodes that have missed their heartbeats after some time
|
||||||
AWX_AUTO_DEPROVISION_INSTANCES = False
|
AWX_AUTO_DEPROVISION_INSTANCES = False
|
||||||
|
|
||||||
|
# If False, do not allow creation of resources that are shared with the platform ingress
|
||||||
|
# e.g. organizations, teams, and users
|
||||||
|
AWX_DIRECT_SHARED_RESOURCE_MANAGEMENT_ENABLED = True
|
||||||
|
|
||||||
# Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed'
|
# Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed'
|
||||||
# Note: This setting may be overridden by database settings.
|
# Note: This setting may be overridden by database settings.
|
||||||
PENDO_TRACKING_STATE = "off"
|
PENDO_TRACKING_STATE = "off"
|
||||||
|
|||||||
3004
awx/sso/conf.py
3004
awx/sso/conf.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user