Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-08-01 20:25:59 +02:00
parent 730e299de4
commit fb8b132de1
No known key found for this signature in database
5 changed files with 197 additions and 101 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -8,7 +8,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView from django.views.generic import RedirectView
from authentik.core.api.applications import ApplicationViewSet 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.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet 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.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet 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.api.users import UserViewSet
from authentik.core.views import apps from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView from authentik.core.views.debug import AccessDeniedView
@ -72,9 +72,9 @@ api_urlpatterns = [
("core/authenticated_sessions", AuthenticatedSessionViewSet), ("core/authenticated_sessions", AuthenticatedSessionViewSet),
("core/applications", ApplicationViewSet), ("core/applications", ApplicationViewSet),
path( path(
"core/applications/create_transactional/", "core/transactional/applications/",
TransactionalApplicationView.as_view(), TransactionalApplicationView.as_view(),
name="core-apps-transactional", name="core-transactional-application",
), ),
("core/groups", GroupViewSet), ("core/groups", GroupViewSet),
("core/users", UserViewSet), ("core/users", UserViewSet),

View File

@ -3091,6 +3091,7 @@ paths:
/core/applications/create_transactional/: /core/applications/create_transactional/:
put: put:
operationId: core_applications_create_transactional_update operationId: core_applications_create_transactional_update
description: Convert data into a blueprint, validate it and apply it
tags: tags:
- core - core
requestBody: requestBody:
@ -3103,7 +3104,11 @@ paths:
- authentik: [] - authentik: []
responses: responses:
'200': '200':
description: No response body content:
application/json:
schema:
$ref: '#/components/schemas/TransactionApplicationResponse'
description: ''
'400': '400':
content: content:
application/json: application/json:
@ -41851,6 +41856,22 @@ components:
- app - app
- provider - provider
- provider_model - 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: TypeCreate:
type: object type: object
description: Types of an object that can be created description: Types of an object that can be created