diff --git a/authentik/pam/api/grant_request.py b/authentik/pam/api/grant_request.py index 195574a3dd..403ef4369d 100644 --- a/authentik/pam/api/grant_request.py +++ b/authentik/pam/api/grant_request.py @@ -1,5 +1,5 @@ from drf_spectacular.utils import extend_schema -from rest_framework.fields import ListField +from rest_framework.decorators import action from rest_framework.mixins import ( DestroyModelMixin, ListModelMixin, @@ -32,7 +32,7 @@ class GrantRequestViewSet(RetrieveModelMixin, DestroyModelMixin, ListModelMixin, serializer_class = GrantRequestSerializer class GrantRequestCreateSerializer(PassiveSerializer): - pbms = ListField(child=PrimaryKeyRelatedField(queryset=PolicyBindingModel.objects.all())) + pbms = PrimaryKeyRelatedField(queryset=PolicyBindingModel.objects.all(), many=True) @extend_schema(request=GrantRequestCreateSerializer, responses={200: LinkSerializer}) @validate(GrantRequestCreateSerializer) @@ -49,4 +49,11 @@ class GrantRequestViewSet(RetrieveModelMixin, DestroyModelMixin, ListModelMixin, }, ) plan.append_stage(in_memory_stage(GrantRequestFinalStageView)) - return Response({"link": plan.to_redirect(request, flow)}) + return Response({"link": plan.to_redirect(request, flow).url}) + + @action(["POST"], detail=True) + def fulfill(self, request: Request, *args, **kwargs): + grant: GrantRequest = self.get_object() + # TODO: Check if this user can fulfill this grant + grant.fulfill(request.user) + return Response(status=204) diff --git a/authentik/pam/migrations/0001_initial.py b/authentik/pam/migrations/0001_initial.py index 32c26ebe46..1b31c0b7aa 100644 --- a/authentik/pam/migrations/0001_initial.py +++ b/authentik/pam/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.14 on 2026-06-05 09:52 +# Generated by Django 5.2.14 on 2026-06-05 13:35 import authentik.core.models import django.db.models.deletion @@ -41,7 +41,19 @@ class Migration(migrations.Migration): ( "created_by", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + on_delete=django.db.models.deletion.CASCADE, + related_name="grant_requests_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "fulfilled_by", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="grant_requests_fulfilled", + to=settings.AUTH_USER_MODEL, ), ), ], diff --git a/authentik/pam/models.py b/authentik/pam/models.py index cece38a4d6..b7ba04a0a9 100644 --- a/authentik/pam/models.py +++ b/authentik/pam/models.py @@ -39,7 +39,16 @@ class GrantRequest(SerializerModel, ExpiringModel, CreatedUpdatedModel): uuid = models.UUIDField(default=uuid4, primary_key=True) - created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="grant_requests_created" + ) + fulfilled_by = models.ForeignKey( + User, + on_delete=models.SET_DEFAULT, + related_name="grant_requests_fulfilled", + null=True, + default=None, + ) # Targets access was requested to targets = models.ManyToManyField(PolicyBindingModel, through="GrantRequestTarget") @@ -55,7 +64,9 @@ class GrantRequest(SerializerModel, ExpiringModel, CreatedUpdatedModel): return GrantRequestSerializer @transaction.atomic - def fulfill(self): + def fulfill(self, user: User): + self.fulfilled_by = user + self.save() if self.status != RequestState.APPROVED: return for target in GrantRequestTarget.objects.filter(request=self).all(): diff --git a/authentik/pam/stage.py b/authentik/pam/stage.py index a2ac0347c8..2332aa35e3 100644 --- a/authentik/pam/stage.py +++ b/authentik/pam/stage.py @@ -2,8 +2,11 @@ from datetime import timedelta from django.db import transaction from django.http import HttpRequest, HttpResponse +from django.urls import reverse from django.utils.timezone import now +from authentik.events.middleware import audit_ignore +from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.pam.models import GrantRequest, GrantRequestTarget, RequestState from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT @@ -17,8 +20,8 @@ class GrantRequestFinalStageView(StageView): user = self.get_pending_user() pbms = self.executor.plan.context.get(PLAN_CONTEXT_GRANT_REQUESTED_PBMS) expires = now() + timedelta(hours=1) - with transaction.atomic(): - req = GrantRequest( + with transaction.atomic(), audit_ignore(): + req = GrantRequest.objects.create( created_by=user, data=self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}), expiring=True, @@ -31,6 +34,13 @@ class GrantRequestFinalStageView(StageView): target=pbm, binding=None, ) + Event.new( + EventAction.MODEL_CREATED, + model=req, + hyperlink=request.build_absolute_uri(reverse("authentik_core:if-admin")) + + "#/pam/requests/respond", + hyperlink_label="Respond", + ).from_http(request, user) return self.executor.stage_ok() def post(self, request: HttpRequest) -> HttpResponse: diff --git a/todo.md b/todo.md index 2b2798c4b9..3f9ab95c52 100644 --- a/todo.md +++ b/todo.md @@ -6,3 +6,4 @@ - [ ] Figure out how credentials for Personas work (API Key?) - [ ] Check if we need a new stage type for requesting access - [ ] Figure out how to configure which apps are "discoverable" +- [ ] Figure out where to configure approvals