core: create app transactional api (#6446)
* initial api and schema Signed-off-by: Jens Langhammer <jens@goauthentik.io> * separate blueprint importer from yaml parsing Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add new "must_created" state to blueprints to prevent overwriting objects Signed-off-by: Jens Langhammer <jens@goauthentik.io> * rework validation and error response to make it actually usable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix lint errors Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add defaults Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * rework transaction_rollback Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use static method for string imports of subclass Signed-off-by: Jens Langhammer <jens@goauthentik.io> * slight cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
139
authentik/core/api/transactional_applications.py
Normal file
139
authentik/core/api/transactional_applications.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""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,
|
||||
EntryInvalidError,
|
||||
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
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
blueprint = Blueprint()
|
||||
blueprint.entries.append(
|
||||
BlueprintEntry(
|
||||
model=attrs["provider_model"],
|
||||
state=BlueprintEntryDesiredState.MUST_CREATED,
|
||||
identifiers={
|
||||
"name": attrs["provider"]["name"],
|
||||
},
|
||||
# Must match the name of the field on `self`
|
||||
id="provider",
|
||||
attrs=attrs["provider"],
|
||||
)
|
||||
)
|
||||
app_data = attrs["app"]
|
||||
app_data["provider"] = KeyOf(None, ScalarNode(tag="", value="provider"))
|
||||
blueprint.entries.append(
|
||||
BlueprintEntry(
|
||||
model="authentik_core.application",
|
||||
state=BlueprintEntryDesiredState.MUST_CREATED,
|
||||
identifiers={
|
||||
"slug": attrs["app"]["slug"],
|
||||
},
|
||||
attrs=app_data,
|
||||
# Must match the name of the field on `self`
|
||||
id="app",
|
||||
)
|
||||
)
|
||||
importer = Importer(blueprint, {})
|
||||
try:
|
||||
valid, _ = importer.validate(raise_validation_errors=True)
|
||||
if not valid:
|
||||
raise ValidationError("Invalid blueprint")
|
||||
except EntryInvalidError as exc:
|
||||
raise ValidationError(
|
||||
{
|
||||
exc.entry_id: exc.validation_error.detail,
|
||||
}
|
||||
)
|
||||
return blueprint
|
||||
|
||||
|
||||
class TransactionApplicationResponseSerializer(PassiveSerializer):
|
||||
"""Transactional creation response"""
|
||||
|
||||
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)
|
||||
|
||||
importer = Importer(data.validated_data, {})
|
||||
applied = importer.apply()
|
||||
response = {"applied": False, "logs": []}
|
||||
response["applied"] = applied
|
||||
return Response(response, status=200)
|
||||
64
authentik/core/tests/test_transactional_applications_api.py
Normal file
64
authentik/core/tests/test_transactional_applications_api.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Test Transactional API"""
|
||||
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),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []})
|
||||
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)
|
||||
|
||||
def test_create_transactional_invalid(self):
|
||||
"""Test transactional Application + provider creation"""
|
||||
self.client.force_login(self.user)
|
||||
uid = generate_id()
|
||||
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": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{"provider": {"authorization_flow": ["This field may not be null."]}},
|
||||
)
|
||||
@ -15,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
|
||||
@ -70,6 +71,11 @@ urlpatterns = [
|
||||
api_urlpatterns = [
|
||||
("core/authenticated_sessions", AuthenticatedSessionViewSet),
|
||||
("core/applications", ApplicationViewSet),
|
||||
path(
|
||||
"core/transactional/applications/",
|
||||
TransactionalApplicationView.as_view(),
|
||||
name="core-transactional-application",
|
||||
),
|
||||
("core/groups", GroupViewSet),
|
||||
("core/users", UserViewSet),
|
||||
("core/tokens", TokenViewSet),
|
||||
|
||||
Reference in New Issue
Block a user