Skip to content

Commit

Permalink
Prevent modifying shared resources when using platform ingress (#15234)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
fosterseth and TheRealHaoLiu authored Jun 5, 2024
1 parent 793777b commit b470ca3
Show file tree
Hide file tree
Showing 5 changed files with 1,581 additions and 1,411 deletions.
90 changes: 88 additions & 2 deletions awx/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

# django-ansible-base
from ansible_base.rbac.models import RoleEvaluation, ObjectRole
from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType

# AWX
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
Expand Down Expand Up @@ -128,6 +129,7 @@
from awx.api.pagination import UnifiedJobEventPagination
from awx.main.utils import set_environ


logger = logging.getLogger('awx.api.views')


Expand Down Expand Up @@ -710,16 +712,81 @@ def get(self, request):
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):
model = models.Team
serializer_class = serializers.TeamSerializer


@immutablesharedfields
class TeamDetail(RetrieveUpdateDestroyAPIView):
model = models.Team
serializer_class = serializers.TeamSerializer


@immutablesharedfields
class TeamUsersList(BaseUsersList):
model = models.User
serializer_class = serializers.UserSerializer
Expand Down Expand Up @@ -1101,6 +1168,7 @@ class ProjectCopy(CopyAPIView):
copy_return_serializer_class = serializers.ProjectSerializer


@immutablesharedfields
class UserList(ListCreateAPIView):
model = models.User
serializer_class = serializers.UserSerializer
Expand Down Expand Up @@ -1271,7 +1339,16 @@ def post(self, request, *args, **kwargs):
user = get_object_or_400(models.User, pk=self.kwargs['pk'])
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 '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"))
Expand Down Expand Up @@ -1343,6 +1420,7 @@ def get_queryset(self):
return qs.filter(Q(actor=parent) | Q(user__in=[parent]))


@immutablesharedfields
class UserDetail(RetrieveUpdateDestroyAPIView):
model = models.User
serializer_class = serializers.UserSerializer
Expand Down Expand Up @@ -4295,7 +4373,15 @@ def post(self, request, *args, **kwargs):
user = get_object_or_400(models.User, pk=sub_id)
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 '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"))
Expand Down
6 changes: 6 additions & 0 deletions awx/api/views/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,18 @@
CredentialSerializer,
)
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin
from awx.api.views import immutablesharedfields

logger = logging.getLogger('awx.api.views.organization')


@immutablesharedfields
class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
model = Organization
serializer_class = OrganizationSerializer


@immutablesharedfields
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Organization
serializer_class = OrganizationSerializer
Expand Down Expand Up @@ -104,6 +107,7 @@ class OrganizationInventoriesList(SubListAPIView):
relationship = 'inventories'


@immutablesharedfields
class OrganizationUsersList(BaseUsersList):
model = User
serializer_class = UserSerializer
Expand All @@ -112,6 +116,7 @@ class OrganizationUsersList(BaseUsersList):
ordering = ('username',)


@immutablesharedfields
class OrganizationAdminsList(BaseUsersList):
model = User
serializer_class = UserSerializer
Expand Down Expand Up @@ -150,6 +155,7 @@ class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
parent_key = 'organization'


@immutablesharedfields
class OrganizationTeamsList(SubListCreateAttachDetachAPIView):
model = Team
serializer_class = TeamSerializer
Expand Down
66 changes: 66 additions & 0 deletions awx/main/tests/functional/api/test_immutablesharedfields.py
Original file line number Diff line number Diff line change
@@ -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']
4 changes: 4 additions & 0 deletions awx/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,10 @@
# Automatically remove nodes that have missed their heartbeats after some time
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'
# Note: This setting may be overridden by database settings.
PENDO_TRACKING_STATE = "off"
Expand Down
Loading

0 comments on commit b470ca3

Please sign in to comment.