Skip to content

Commit

Permalink
core: add support to set policy bindings in transactional endpoint (#…
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
BeryJu authored Nov 17, 2024
1 parent 550e24e commit 4859dc7
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 14 deletions.
15 changes: 10 additions & 5 deletions authentik/blueprints/v1/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,11 @@ def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None: # n

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,
Expand All @@ -303,11 +307,12 @@ def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer | None: # n
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:
Expand Down
51 changes: 45 additions & 6 deletions authentik/core/api/transactional_applications.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +24,7 @@
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():
Expand All @@ -45,13 +48,22 @@ 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"""

app = ApplicationSerializer()
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:
Expand Down Expand Up @@ -96,6 +108,19 @@ def validate(self, attrs: dict) -> dict:
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)
Expand All @@ -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(),
Expand All @@ -133,8 +157,23 @@ 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, {})
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
Expand Down
98 changes: 95 additions & 3 deletions authentik/core/tests/test_transactional_applications_api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
"""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


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"""
Expand Down Expand Up @@ -41,6 +45,65 @@ def test_create_transactional(self):
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)
Expand Down Expand Up @@ -69,3 +132,32 @@ def test_create_transactional_invalid(self):
}
},
)

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 changes: 39 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 4859dc7

Please sign in to comment.