Skip to content

Commit 783def4

Browse files
authored
Merge branch 'devel' into ansible_id_assurance
2 parents 88e0cc9 + 0e4879c commit 783def4

File tree

6 files changed

+94
-12
lines changed

6 files changed

+94
-12
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .authenticator import AuthenticatorSerializer # noqa: 401
1+
from .authenticator import AuthenticatorSerializer, AuthenticatorUpdateSerializer # noqa: 401
22
from .authenticator_map import AuthenticatorMapSerializer # noqa: 401
33
from .ui_auth import PasswordAuthenticatorSerializer, SSOAuthenticatorSerializer, UIAuthResponseSerializer # noqa: 401

ansible_base/authentication/serializers/authenticator.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,12 @@ def _get_related(self, obj) -> dict[str, str]:
146146
related['users'] = get_relative_url('authenticator-users-list', kwargs={'pk': obj.pk})
147147

148148
return related
149+
150+
151+
class AuthenticatorUpdateSerializer(AuthenticatorSerializer):
152+
"""Specialized serializer for update operations that makes type field read-only."""
153+
154+
type = ChoiceField(choices=get_authenticator_plugins(), read_only=True, help_text="The type of authentication service this is.")
155+
156+
class Meta(AuthenticatorSerializer.Meta):
157+
read_only_fields = getattr(AuthenticatorSerializer.Meta, 'read_only_fields', []) + ['type']

ansible_base/authentication/serializers/authenticator_map.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,22 @@
88
from ansible_base.authentication.utils.authenticator_map import _EXPANSION_FIELDS, check_expansion_syntax, check_role_type, has_expansion
99
from ansible_base.authentication.utils.trigger_definition import TRIGGER_DEFINITION
1010
from ansible_base.lib.serializers.common import NamedCommonModelSerializer
11+
from ansible_base.lib.serializers.fields import JSONField
1112
from ansible_base.lib.utils.string import is_empty
1213
from ansible_base.lib.utils.typing import TranslatedString
1314

1415
logger = logging.getLogger('ansible_base.authentication.serializers.authenticator_map')
1516

1617

1718
class AuthenticatorMapSerializer(NamedCommonModelSerializer):
19+
triggers = JSONField(
20+
required=True,
21+
allow_null=False,
22+
error_messages={'required': 'Triggers must be a valid dict'},
23+
help_text="Required. Trigger conditions dictionary that determines when this map applies. "
24+
"See /trigger_definition/ for structure details. Only one top-level key per request.",
25+
)
26+
1827
class Meta:
1928
model = AuthenticatorMap
2029
fields = NamedCommonModelSerializer.Meta.fields + ['authenticator', 'map_type', 'role', 'organization', 'team', 'revoke', 'triggers', 'order']

ansible_base/authentication/views/authenticator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from rest_framework.viewsets import ModelViewSet
66

77
from ansible_base.authentication.models import Authenticator, AuthenticatorUser
8-
from ansible_base.authentication.serializers import AuthenticatorSerializer
8+
from ansible_base.authentication.serializers import AuthenticatorSerializer, AuthenticatorUpdateSerializer
99
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
1010

1111
logger = logging.getLogger('ansible_base.authentication.views.authenticator')
@@ -36,6 +36,11 @@ def get_serializer(self, *args, **kwargs):
3636

3737
return super().get_serializer(*args, **kwargs)
3838

39+
def get_serializer_class(self):
40+
if self.action in ['update', 'partial_update']:
41+
return AuthenticatorUpdateSerializer
42+
return super().get_serializer_class()
43+
3944
def destroy(self, request, *args, **kwargs):
4045
instance = self.get_object()
4146

ansible_base/rbac/api/views.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from rest_framework.viewsets import GenericViewSet, ModelViewSet, mixins
1515

1616
from ansible_base.lib.utils.auth import get_team_model, get_user_model
17+
from ansible_base.lib.utils.schema import extend_schema_if_available
1718
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
1819
from ansible_base.lib.utils.views.permissions import try_add_oauth2_scope_permission
1920
from ansible_base.rbac.api.permissions import RoleDefinitionPermissions
@@ -236,17 +237,24 @@ class RoleTeamAssignmentViewSet(BaseAssignmentViewSet):
236237
]
237238

238239

239-
class RoleUserAssignmentViewSet(BaseAssignmentViewSet):
240-
"""
241-
Use this endpoint to give a user permission to a resource or an organization.
242-
The needed data is the user, the role definition, and the object id.
243-
The object must be of the type specified in the role definition.
244-
The type given in the role definition and the provided object_id are used
245-
to look up the resource.
240+
# Schema fragments for RoleUserAssignmentViewSet OpenAPI spec
241+
_USER_ACTOR_ONEOF = {
242+
'oneOf': [
243+
{'required': ['user'], 'not': {'required': ['user_ansible_id']}},
244+
{'required': ['user_ansible_id'], 'not': {'required': ['user']}},
245+
]
246+
}
247+
248+
_OBJECT_ID_ONEOF = {
249+
'oneOf': [
250+
{'properties': {'object_id': {'oneOf': [{'type': 'integer'}, {'type': 'string', 'format': 'uuid'}]}, 'object_ansible_id': False}},
251+
{'properties': {'object_ansible_id': {'type': 'string', 'format': 'uuid'}, 'object_id': False}},
252+
{'not': {'anyOf': [{'required': ['object_id']}, {'required': ['object_ansible_id']}]}},
253+
]
254+
}
246255

247-
After creation, the assignment cannot be edited, but can be deleted to
248-
remove those permissions.
249-
"""
256+
257+
class RoleUserAssignmentViewSet(BaseAssignmentViewSet):
250258

251259
resource_purpose = "RBAC role grants assigning permissions to users for specific resources"
252260

@@ -257,6 +265,25 @@ class RoleUserAssignmentViewSet(BaseAssignmentViewSet):
257265
ansible_id_backend.RoleAssignmentFilterBackend,
258266
]
259267

268+
@extend_schema_if_available(
269+
request={
270+
'application/json': {
271+
'allOf': [
272+
{'$ref': '#/components/schemas/RoleUserAssignment'},
273+
_USER_ACTOR_ONEOF,
274+
_OBJECT_ID_ONEOF,
275+
]
276+
},
277+
},
278+
description="Give a user permission to a resource, an organization, or globally (when allowed)."
279+
"Must specify 'role_definition' and exactly one of 'user' or 'user_ansible_id'."
280+
"Can specify at most one of 'object_id' or 'object_ansible_id' (omit both for global roles)."
281+
"The content_type of the role definition and the provided object_id are used to look up the resource."
282+
"After creation, the assignment cannot be edited, but can be deleted to remove those permissions.",
283+
)
284+
def create(self, request, *args, **kwargs):
285+
return super().create(request, *args, **kwargs)
286+
260287

261288
class AccessURLMixin:
262289
def get_actor_model(self):

test_app/tests/api_documentation/test_schema_integration.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,35 @@ def test_openapi_schema_unauthenticated_access(unauthenticated_api_client):
148148
if response.status_code == 200:
149149
schema = response.data
150150
assert 'openapi' in schema
151+
152+
153+
def test_role_user_assignment_create_schema(admin_api_client):
154+
"""
155+
Test that RoleUserAssignmentViewSet's create operation has proper schema constraints.
156+
157+
Generated by Claude Code (claude-sonnet-4-5@20250929)
158+
159+
Verifies that the request body schema properly enforces:
160+
- Exactly one of 'user' or 'user_ansible_id' is required
161+
- At most one of 'object_id' or 'object_ansible_id' can be specified
162+
"""
163+
url = '/api/v1/docs/schema/'
164+
response = admin_api_client.get(url)
165+
assert response.status_code == 200
166+
167+
# Navigate directly to the schema - will raise KeyError if path doesn't exist
168+
all_of = response.data['paths']['/api/v1/role_user_assignments/']['post']['requestBody']['content']['application/json']['schema']['allOf']
169+
170+
# Verify structure: [base_schema, user_constraint, object_constraint]
171+
assert len(all_of) == 3, "Should have 3 items: base schema + 2 constraint sets"
172+
assert all_of[0] == {'$ref': '#/components/schemas/RoleUserAssignment'}
173+
174+
# Verify user constraint: exactly one of 'user' or 'user_ansible_id'
175+
user_one_of = all_of[1]['oneOf']
176+
assert len(user_one_of) == 2
177+
assert user_one_of[0] == {'required': ['user'], 'not': {'required': ['user_ansible_id']}}
178+
assert user_one_of[1] == {'required': ['user_ansible_id'], 'not': {'required': ['user']}}
179+
180+
# Verify object constraint: at most one of 'object_id' or 'object_ansible_id'
181+
object_one_of = all_of[2]['oneOf']
182+
assert len(object_one_of) == 3

0 commit comments

Comments
 (0)