diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index f0cc804f52..ff272b5c62 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -293,7 +293,11 @@ class Importer: serializer_kwargs = {} model_instance = existing_models.first() - if not isinstance(model(), BaseMetaModel) and model_instance: + if ( + not isinstance(model(), BaseMetaModel) + and model_instance + and entry.state != BlueprintEntryDesiredState.MUST_CREATED + ): self.logger.debug( "Initialise serializer with instance", model=model, @@ -303,11 +307,12 @@ class Importer: serializer_kwargs["instance"] = model_instance serializer_kwargs["partial"] = True elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: + msg = ( + f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} " + "and object exists already", + ) raise EntryInvalidError.from_entry( - ( - f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} " - "and object exists already", - ), + ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"), entry, ) else: diff --git a/authentik/core/api/transactional_applications.py b/authentik/core/api/transactional_applications.py index 1e47096ca6..44e390ecd1 100644 --- a/authentik/core/api/transactional_applications.py +++ b/authentik/core/api/transactional_applications.py @@ -1,10 +1,12 @@ """transactional application and provider creation""" from django.apps import apps +from django.db.models import Model +from django.utils.translation import gettext as _ from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -22,6 +24,7 @@ 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 +from authentik.policies.api.bindings import PolicyBindingSerializer def get_provider_serializer_mapping(): @@ -45,6 +48,13 @@ class TransactionProviderField(DictField): """Dictionary field which can hold provider creation data""" +class TransactionPolicyBindingSerializer(PolicyBindingSerializer): + """PolicyBindingSerializer which does not require target as target is set implicitly""" + + class Meta(PolicyBindingSerializer.Meta): + fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"] + + class TransactionApplicationSerializer(PassiveSerializer): """Serializer for creating a provider and an application in one transaction""" @@ -52,6 +62,8 @@ class TransactionApplicationSerializer(PassiveSerializer): provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys())) provider = TransactionProviderField() + policy_bindings = TransactionPolicyBindingSerializer(many=True, required=False) + _provider_model: type[Provider] = None def validate_provider_model(self, fq_model_name: str) -> str: @@ -96,6 +108,19 @@ class TransactionApplicationSerializer(PassiveSerializer): id="app", ) ) + for binding in attrs.get("policy_bindings", []): + binding["target"] = KeyOf(None, ScalarNode(tag="", value="app")) + for key, value in binding.items(): + if not isinstance(value, Model): + continue + binding[key] = value.pk + blueprint.entries.append( + BlueprintEntry( + model="authentik_policies.policybinding", + state=BlueprintEntryDesiredState.MUST_CREATED, + identifiers=binding, + ) + ) importer = Importer(blueprint, {}) try: valid, _ = importer.validate(raise_validation_errors=True) @@ -120,8 +145,7 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): class TransactionalApplicationView(APIView): """Create provider and application and attach them in a single transaction""" - # TODO: Migrate to a more specific permission - permission_classes = [IsAdminUser] + permission_classes = [IsAuthenticated] @extend_schema( request=TransactionApplicationSerializer(), @@ -133,8 +157,23 @@ class TransactionalApplicationView(APIView): """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, {}) + blueprint: Blueprint = data.validated_data + for entry in blueprint.entries: + full_model = entry.get_model(blueprint) + app, __, model = full_model.partition(".") + if not request.user.has_perm(f"{app}.add_{model}"): + raise PermissionDenied( + { + entry.id: _( + "User lacks permission to create {model}".format_map( + { + "model": full_model, + } + ) + ) + } + ) + importer = Importer(blueprint, {}) applied = importer.apply() response = {"applied": False, "logs": []} response["applied"] = applied diff --git a/authentik/core/tests/test_transactional_applications_api.py b/authentik/core/tests/test_transactional_applications_api.py index d0804fb3b6..ad122096b1 100644 --- a/authentik/core/tests/test_transactional_applications_api.py +++ b/authentik/core/tests/test_transactional_applications_api.py @@ -1,11 +1,13 @@ """Test Transactional API""" from django.urls import reverse +from guardian.shortcuts import assign_perm 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.core.models import Application, Group +from authentik.core.tests.utils import create_test_flow, create_test_user from authentik.lib.generators import generate_id +from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.models import OAuth2Provider @@ -13,7 +15,9 @@ class TestTransactionalApplicationsAPI(APITestCase): """Test Transactional API""" def setUp(self) -> None: - self.user = create_test_admin_user() + self.user = create_test_user() + assign_perm("authentik_core.add_application", self.user) + assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user) def test_create_transactional(self): """Test transactional Application + provider creation""" @@ -41,6 +45,65 @@ class TestTransactionalApplicationsAPI(APITestCase): self.assertIsNotNone(app) self.assertEqual(app.provider.pk, provider.pk) + def test_create_transactional_permission_denied(self): + """Test transactional Application + provider creation (missing permissions)""" + 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_saml.samlprovider", + "provider": { + "name": uid, + "authorization_flow": str(create_test_flow().pk), + "invalidation_flow": str(create_test_flow().pk), + "acs_url": "https://goauthentik.io", + }, + }, + ) + self.assertJSONEqual( + response.content.decode(), + {"provider": "User lacks permission to create authentik_providers_saml.samlprovider"}, + ) + + def test_create_transactional_bindings(self): + """Test transactional Application + provider creation""" + assign_perm("authentik_policies.add_policybinding", self.user) + self.client.force_login(self.user) + uid = generate_id() + group = Group.objects.create(name=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), + "invalidation_flow": str(authorization_flow.pk), + }, + "policy_bindings": [{"group": group.pk, "order": 0}], + }, + ) + 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) + binding = PolicyBinding.objects.filter(target=app).first() + self.assertIsNotNone(binding) + self.assertEqual(binding.target, app) + self.assertEqual(binding.group, group) + def test_create_transactional_invalid(self): """Test transactional Application + provider creation""" self.client.force_login(self.user) @@ -69,3 +132,32 @@ class TestTransactionalApplicationsAPI(APITestCase): } }, ) + + def test_create_transactional_duplicate_name_provider(self): + """Test transactional Application + provider creation""" + self.client.force_login(self.user) + uid = generate_id() + OAuth2Provider.objects.create( + name=uid, + authorization_flow=create_test_flow(), + invalidation_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(create_test_flow().pk), + "invalidation_flow": str(create_test_flow().pk), + }, + }, + ) + self.assertJSONEqual( + response.content.decode(), + {"provider": {"name": ["State is set to must_created and object exists already"]}}, + ) diff --git a/schema.yml b/schema.yml index c3c8c1212d..bf54f4a662 100644 --- a/schema.yml +++ b/schema.yml @@ -54680,6 +54680,10 @@ components: $ref: '#/components/schemas/ProviderModelEnum' provider: $ref: '#/components/schemas/modelRequest' + policy_bindings: + type: array + items: + $ref: '#/components/schemas/TransactionPolicyBindingRequest' required: - app - provider @@ -54697,6 +54701,41 @@ components: required: - applied - logs + TransactionPolicyBindingRequest: + type: object + description: PolicyBindingSerializer which does not require target as target + is set implicitly + properties: + policy: + type: string + format: uuid + nullable: true + group: + type: string + format: uuid + nullable: true + user: + type: integer + nullable: true + negate: + type: boolean + description: Negates the outcome of the policy. Messages are unaffected. + enabled: + type: boolean + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + timeout: + type: integer + maximum: 2147483647 + minimum: 0 + description: Timeout after which Policy execution is terminated. + failure_result: + type: boolean + description: Result if the Policy execution fails. + required: + - order TypeCreate: type: object description: Types of an object that can be created