diff --git a/authentik/core/api/applications_transactional.py b/authentik/core/api/applications_transactional.py deleted file mode 100644 index 31adb48a3..000000000 --- a/authentik/core/api/applications_transactional.py +++ /dev/null @@ -1,97 +0,0 @@ -from django.apps import apps -from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field -from rest_framework.exceptions import ValidationError -from rest_framework.fields import ChoiceField, DictField -from rest_framework.permissions import IsAdminUser -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView -from yaml import ScalarNode -from authentik.blueprints.v1.common import Blueprint, BlueprintEntry, BlueprintEntryDesiredState, KeyOf -from authentik.blueprints.v1.importer import Importer - -from authentik.core.api.applications import ApplicationSerializer -from authentik.core.api.utils import PassiveSerializer -from authentik.core.models import Provider -from authentik.lib.utils.reflection import all_subclasses - - -def get_provider_serializer_mapping(): - map = {} - for model in all_subclasses(Provider): - if model._meta.abstract: - continue - map[f"{model._meta.app_label}.{model._meta.model_name}"] = model().serializer - return map - - -@extend_schema_field( - PolymorphicProxySerializer( - component_name="model", - serializers=get_provider_serializer_mapping, - resource_type_field_name="provider_model", - ) -) -class TransactionProviderField(DictField): - pass - - -class TransactionApplicationSerializer(PassiveSerializer): - """Serializer for creating a provider and an application in one transaction""" - - app = ApplicationSerializer() - provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys())) - provider = TransactionProviderField() - - _provider_model: type[Provider] = None - - def validate_provider_model(self, fq_model_name: str) -> str: - """Validate that the model exists and is a provider""" - if "." not in fq_model_name: - raise ValidationError("Invalid provider model") - try: - app, model_name = fq_model_name.split(".") - model = apps.get_model(app, model_name) - if not issubclass(model, Provider): - raise ValidationError("Invalid provider model") - self._provider_model = model - except LookupError: - raise ValidationError("Invalid provider model") - return fq_model_name - - def validate_provider(self, provider: dict) -> dict: - """Validate provider data""" - # ensure the model has been validated - self.validate_provider_model(self.initial_data["provider_model"]) - model_serializer = self._provider_model().serializer(data=provider) - model_serializer.is_valid(raise_exception=True) - return model_serializer.validated_data - - -class TransactionalApplicationView(APIView): - permission_classes = [IsAdminUser] - - @extend_schema(request=TransactionApplicationSerializer()) - def put(self, request: Request) -> Response: - data = TransactionApplicationSerializer(data=request.data) - data.is_valid(raise_exception=True) - print(data.validated_data) - - blueprint = Blueprint() - blueprint.entries.append(BlueprintEntry( - model=data.validated_data["provider_model"], - state=BlueprintEntryDesiredState.PRESENT, - identifiers={}, - id="provider", - attrs=data.validated_data["provider"], - )) - app_data = data.validated_data["app"] - app_data["provider"] = KeyOf(None, ScalarNode(value="provider")) - blueprint.entries.append(BlueprintEntry( - model="authentik_core.application", - state=BlueprintEntryDesiredState.PRESENT, - identifiers={}, - attrs=app_data, - )) - importer = Importer("", {}) - return Response(status=200) diff --git a/authentik/core/api/transactional_applications.py b/authentik/core/api/transactional_applications.py new file mode 100644 index 000000000..2f3110029 --- /dev/null +++ b/authentik/core/api/transactional_applications.py @@ -0,0 +1,127 @@ +"""transactional application and provider creation""" +from django.apps import apps +from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field +from rest_framework.exceptions import ValidationError +from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from yaml import ScalarNode + +from authentik.blueprints.v1.common import ( + Blueprint, + BlueprintEntry, + BlueprintEntryDesiredState, + KeyOf, +) +from authentik.blueprints.v1.importer import Importer +from authentik.core.api.applications import ApplicationSerializer +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import Provider +from authentik.lib.utils.reflection import all_subclasses + + +def get_provider_serializer_mapping(): + """Get a mapping of all providers' model names and their serializers""" + mapping = {} + for model in all_subclasses(Provider): + if model._meta.abstract: + continue + mapping[f"{model._meta.app_label}.{model._meta.model_name}"] = model().serializer + return mapping + + +@extend_schema_field( + PolymorphicProxySerializer( + component_name="model", + serializers=get_provider_serializer_mapping, + resource_type_field_name="provider_model", + ) +) +class TransactionProviderField(DictField): + """Dictionary field which can hold provider creation data""" + + +class TransactionApplicationSerializer(PassiveSerializer): + """Serializer for creating a provider and an application in one transaction""" + + app = ApplicationSerializer() + provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys())) + provider = TransactionProviderField() + + _provider_model: type[Provider] = None + + def validate_provider_model(self, fq_model_name: str) -> str: + """Validate that the model exists and is a provider""" + if "." not in fq_model_name: + raise ValidationError("Invalid provider model") + try: + app, _, model_name = fq_model_name.partition(".") + model = apps.get_model(app, model_name) + if not issubclass(model, Provider): + raise ValidationError("Invalid provider model") + self._provider_model = model + except LookupError: + raise ValidationError("Invalid provider model") + return fq_model_name + + +class TransactionApplicationResponseSerializer(PassiveSerializer): + """Transactional creation response""" + + valid = BooleanField() + applied = BooleanField() + logs = ListField(child=CharField()) + + +class TransactionalApplicationView(APIView): + """Create provider and application and attach them in a single transaction""" + + permission_classes = [IsAdminUser] + + @extend_schema( + request=TransactionApplicationSerializer(), + responses={ + 200: TransactionApplicationResponseSerializer(), + }, + ) + def put(self, request: Request) -> Response: + """Convert data into a blueprint, validate it and apply it""" + data = TransactionApplicationSerializer(data=request.data) + data.is_valid(raise_exception=True) + print(data.validated_data) + + blueprint = Blueprint() + blueprint.entries.append( + BlueprintEntry( + model=data.validated_data["provider_model"], + state=BlueprintEntryDesiredState.PRESENT, + identifiers={ + "name": data.validated_data["provider"]["name"], + }, + id="provider", + attrs=data.validated_data["provider"], + ) + ) + app_data = data.validated_data["app"] + app_data["provider"] = KeyOf(None, ScalarNode(tag="", value="provider")) + blueprint.entries.append( + BlueprintEntry( + model="authentik_core.application", + state=BlueprintEntryDesiredState.PRESENT, + identifiers={ + "slug": data.validated_data["app"]["slug"], + }, + attrs=app_data, + ) + ) + importer = Importer(blueprint, {}) + response = {"valid": False, "applied": False, "logs": []} + valid, logs = importer.validate() + response["logs"] = [x["event"] for x in logs] + response["valid"] = valid + if valid: + applied = importer.apply() + response["applied"] = applied + return Response(response, status=200) diff --git a/authentik/core/tests/test_transactional_applications_api.py b/authentik/core/tests/test_transactional_applications_api.py new file mode 100644 index 000000000..beea6b5ee --- /dev/null +++ b/authentik/core/tests/test_transactional_applications_api.py @@ -0,0 +1,45 @@ +"""Test Transactional API""" +from json import loads + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.models import OAuth2Provider + + +class TestTransactionalApplicationsAPI(APITestCase): + """Test Transactional API""" + + def setUp(self) -> None: + self.user = create_test_admin_user() + + def test_create_transactional(self): + """Test transactional Application + provider creation""" + self.client.force_login(self.user) + uid = generate_id() + authorization_flow = create_test_flow() + response = self.client.put( + reverse("authentik_api:core-transactional-application"), + data={ + "app": { + "name": uid, + "slug": uid, + }, + "provider_model": "authentik_providers_oauth2.oauth2provider", + "provider": { + "name": uid, + "authorization_flow": str(authorization_flow.pk), + }, + }, + ) + response_body = loads(response.content.decode()) + self.assertTrue(response_body["valid"]) + self.assertTrue(response_body["applied"]) + provider = OAuth2Provider.objects.filter(name=uid).first() + self.assertIsNotNone(provider) + app = Application.objects.filter(slug=uid).first() + self.assertIsNotNone(app) + self.assertEqual(app.provider.pk, provider.pk) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 8e16e56bd..0914d4e88 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -8,7 +8,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import RedirectView from authentik.core.api.applications import ApplicationViewSet -from authentik.core.api.applications_transactional import TransactionalApplicationView from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet from authentik.core.api.groups import GroupViewSet @@ -16,6 +15,7 @@ from authentik.core.api.propertymappings import PropertyMappingViewSet from authentik.core.api.providers import ProviderViewSet from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.tokens import TokenViewSet +from authentik.core.api.transactional_applications import TransactionalApplicationView from authentik.core.api.users import UserViewSet from authentik.core.views import apps from authentik.core.views.debug import AccessDeniedView @@ -72,9 +72,9 @@ api_urlpatterns = [ ("core/authenticated_sessions", AuthenticatedSessionViewSet), ("core/applications", ApplicationViewSet), path( - "core/applications/create_transactional/", + "core/transactional/applications/", TransactionalApplicationView.as_view(), - name="core-apps-transactional", + name="core-transactional-application", ), ("core/groups", GroupViewSet), ("core/users", UserViewSet), diff --git a/schema.yml b/schema.yml index 8d22ee624..18f781b7d 100644 --- a/schema.yml +++ b/schema.yml @@ -3091,6 +3091,7 @@ paths: /core/applications/create_transactional/: put: operationId: core_applications_create_transactional_update + description: Convert data into a blueprint, validate it and apply it tags: - core requestBody: @@ -3103,7 +3104,11 @@ paths: - authentik: [] responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionApplicationResponse' + description: '' '400': content: application/json: @@ -41851,6 +41856,22 @@ components: - app - provider - provider_model + TransactionApplicationResponse: + type: object + description: Transactional creation response + properties: + valid: + type: boolean + applied: + type: boolean + logs: + type: array + items: + type: string + required: + - applied + - logs + - valid TypeCreate: type: object description: Types of an object that can be created