core: add support to set policy bindings in transactional endpoint (#10399)
* core: add support to set policy bindings in transactional endpoint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improve permission checks especially since we'll be using the wizard as default in the future, it shouldn't be superuser only Signed-off-by: Jens Langhammer <jens@goauthentik.io> * rebase, fix error response when using duplicate name in provider Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add permission test Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -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:
|
||||
raise EntryInvalidError.from_entry(
|
||||
(
|
||||
f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} "
|
||||
msg = (
|
||||
f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} "
|
||||
"and object exists already",
|
||||
),
|
||||
)
|
||||
raise EntryInvalidError.from_entry(
|
||||
ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"),
|
||||
entry,
|
||||
)
|
||||
else:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]}},
|
||||
)
|
||||
|
||||
39
schema.yml
39
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
|
||||
|
||||
Reference in New Issue
Block a user