From 7c65b8a8d3213618543267df5e279b482888c2dd Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Sun, 12 Apr 2026 22:07:38 -0400 Subject: [PATCH 01/23] enterprise/agent user types + tests first pass --- authentik/core/api/applications.py | 28 +- authentik/core/api/tokens.py | 33 ++ authentik/core/api/users.py | 225 +++++++++++ .../management/commands/change_user_type.py | 1 + .../core/migrations/0058_user_agent_type.py | 42 ++ authentik/core/models.py | 8 + authentik/core/signals.py | 31 ++ authentik/core/tests/test_token_api.py | 44 +++ authentik/core/tests/test_users.py | 95 ++++- authentik/core/tests/test_users_api.py | 176 +++++++++ authentik/core/views/interface.py | 2 + .../providers/google_workspace/models.py | 6 +- .../providers/microsoft_entra/models.py | 6 +- authentik/enterprise/tasks.py | 14 +- authentik/enterprise/tests/test_tasks.py | 64 +++ authentik/events/utils.py | 2 +- authentik/policies/expression/evaluator.py | 46 +++ authentik/policies/expression/tests.py | 87 ++++- authentik/providers/scim/models.py | 6 +- blueprints/schema.json | 9 +- packages/client-go/api_core.go | 363 ++++++++++++++++++ .../model_user_agent_allowed_apps.go | 167 ++++++++ .../model_user_agent_allowed_apps_request.go | 167 ++++++++ .../client-go/model_user_agent_request.go | 204 ++++++++++ .../client-go/model_user_agent_response.go | 254 ++++++++++++ packages/client-go/model_user_type_enum.go | 2 + packages/client-rust/src/apis/core_api.rs | 217 +++++++++++ packages/client-rust/src/models/mod.rs | 8 + .../src/models/user_agent_allowed_apps.rs | 25 ++ .../models/user_agent_allowed_apps_request.rs | 25 ++ .../src/models/user_agent_request.rs | 27 ++ .../src/models/user_agent_response.rs | 39 ++ .../client-rust/src/models/user_type_enum.rs | 3 + packages/client-ts/src/apis/CoreApi.ts | 229 +++++++++++ .../src/models/UserAgentAllowedApps.ts | 68 ++++ .../src/models/UserAgentAllowedAppsRequest.ts | 70 ++++ .../client-ts/src/models/UserAgentRequest.ts | 76 ++++ .../client-ts/src/models/UserAgentResponse.ts | 95 +++++ packages/client-ts/src/models/UserTypeEnum.ts | 1 + packages/client-ts/src/models/index.ts | 4 + schema.yml | 138 +++++++ web/src/admin/users/AgentForm.ts | 107 ++++++ web/src/admin/users/UserForm.ts | 63 ++- web/src/admin/users/ak-user-wizard.ts | 107 +++++- web/src/common/labels.ts | 1 + 45 files changed, 3341 insertions(+), 44 deletions(-) create mode 100644 authentik/core/migrations/0058_user_agent_type.py create mode 100644 authentik/enterprise/tests/test_tasks.py create mode 100644 packages/client-go/model_user_agent_allowed_apps.go create mode 100644 packages/client-go/model_user_agent_allowed_apps_request.go create mode 100644 packages/client-go/model_user_agent_request.go create mode 100644 packages/client-go/model_user_agent_response.go create mode 100644 packages/client-rust/src/models/user_agent_allowed_apps.rs create mode 100644 packages/client-rust/src/models/user_agent_allowed_apps_request.rs create mode 100644 packages/client-rust/src/models/user_agent_request.rs create mode 100644 packages/client-rust/src/models/user_agent_response.rs create mode 100644 packages/client-ts/src/models/UserAgentAllowedApps.ts create mode 100644 packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts create mode 100644 packages/client-ts/src/models/UserAgentRequest.ts create mode 100644 packages/client-ts/src/models/UserAgentResponse.ts create mode 100644 web/src/admin/users/AgentForm.ts diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index e963f94568a6..0c54529f258b 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -26,7 +26,13 @@ from authentik.core.api.users import UserSerializer from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer from authentik.core.apps import AppAccessWithoutBindings -from authentik.core.models import Application, User +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, + Application, + User, + UserTypes, +) from authentik.events.logs import LogEventSerializer, capture_logs from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.engine import PolicyEngine @@ -164,9 +170,29 @@ def _get_allowed_applications( ) -> list[Application]: applications = [] request = self.request._request + check_user = user or request.user if user: request = copy(request) request.user = user + + if check_user.type == UserTypes.AGENT: + allowed_pks = set( + check_user.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS, []) + ) + owner_pk = check_user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + owner = User.objects.filter(pk=owner_pk).first() if owner_pk else None + if not owner: + return [] + for application in paginated_apps: + if str(application.pk) not in allowed_pks: + continue + engine = PolicyEngine(application, owner, request) + engine.empty_result = AppAccessWithoutBindings.get() + engine.build() + if engine.passing: + applications.append(application) + return applications + for application in paginated_apps: engine = PolicyEngine(application, request.user, request) engine.empty_result = AppAccessWithoutBindings.get() diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index 8529c677e187..aaccd5639b61 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -1,5 +1,6 @@ """Tokens API Viewset""" +from datetime import timedelta from typing import Any from django.utils.timezone import now @@ -18,12 +19,15 @@ from authentik.core.api.users import UserSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, Token, TokenIntents, User, + UserTypes, default_token_duration, + default_token_key, ) from authentik.events.models import Event, EventAction from authentik.events.utils import model_to_dict @@ -171,6 +175,35 @@ def view_key(self, request: Request, identifier: str) -> Response: Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec return Response(TokenViewSerializer({"key": token.key}).data) + @extend_schema( + request=None, + responses={ + 200: TokenViewSerializer(many=False), + 403: OpenApiResponse(description="Not the token owner, agent owner, or superuser"), + }, + ) + @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) + def rotate(self, request: Request, identifier: str) -> Response: + """Rotate the token key and reset the expiry to 24 hours. Only callable by the token + owner, the owning agent's human owner, or a superuser.""" + token: Token = self.get_object() + + if not request.user.is_superuser: + is_token_owner = token.user_id == request.user.pk + is_agent_owner = ( + token.user.type == UserTypes.AGENT + and str(request.user.pk) + == token.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + ) + if not is_token_owner and not is_agent_owner: + return Response(status=403) + + token.key = default_token_key() + token.expires = now() + timedelta(hours=24) + token.save() + Event.new(EventAction.SECRET_ROTATE, secret=token).from_http(request) # noqa # nosec + return Response(TokenViewSerializer({"key": token.key}).data) + @permission_required("authentik_core.set_token_key") @extend_schema( request=TokenSetKeySerializer(), diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index eaa23b99ac3e..ce233f3849ed 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -75,9 +75,13 @@ SESSION_KEY_IMPERSONATE_USER, ) from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, USER_ATTRIBUTE_TOKEN_EXPIRING, + USER_PATH_AGENT, USER_PATH_SERVICE_ACCOUNT, USERNAME_MAX_LENGTH, + Application, Group, Session, Token, @@ -88,6 +92,7 @@ ) from authentik.endpoints.connectors.agent.auth import AgentAuth from authentik.events.models import Event, EventAction +from authentik.events.utils import model_to_dict, sanitize_dict from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import FlowToken from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner @@ -249,8 +254,25 @@ def validate_type(self, user_type: str) -> str: raise ValidationError(_("Can't change internal service account to other user type.")) if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value: raise ValidationError(_("Setting a user to internal service account is not allowed.")) + if ( + self.instance + and self.instance.type == UserTypes.AGENT + and user_type != UserTypes.AGENT.value + ): + raise ValidationError(_("Can't change agent user type.")) return user_type + def validate_attributes(self, attrs: dict) -> dict: + """Prevent changes to agent owner""" + if not self.instance: + return attrs + if self.instance.type == UserTypes.AGENT: + existing_owner = self.instance.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + new_owner = attrs.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + if existing_owner is not None and new_owner != existing_owner: + raise ValidationError(_("Can't change owner of agent user.")) + return attrs + def validate(self, attrs: dict) -> dict: if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: raise ValidationError(_("Can't modify internal service account users")) @@ -405,6 +427,19 @@ class UserServiceAccountSerializer(PassiveSerializer): ) +class UserAgentSerializer(PassiveSerializer): + """Payload to create an agent user""" + + name = CharField(max_length=150) + owner = PrimaryKeyRelatedField(queryset=User.objects.all(), required=False, default=None) + + +class UserAgentAllowedAppsSerializer(PassiveSerializer): + """Payload to update an agent's allowed applications""" + + allowed_apps = ListField(child=UUIDField()) + + class UserRecoveryLinkSerializer(PassiveSerializer): """Payload to create a recovery link""" @@ -691,6 +726,196 @@ def service_account(self, request: Request, body: UserServiceAccountSerializer) status=500, ) + @permission_required(None, ["authentik_core.add_agent_user"]) + @extend_schema( + request=UserAgentSerializer, + responses={ + 200: inline_serializer( + "UserAgentResponse", + { + "username": CharField(required=True), + "token": CharField(required=True), + "user_uid": CharField(required=True), + "user_pk": IntegerField(required=True), + }, + ) + }, + ) + @action( + detail=False, + methods=["POST"], + pagination_class=None, + filter_backends=[], + ) + @validate(UserAgentSerializer) + def agent(self, request: Request, body: UserAgentSerializer) -> Response: + """Create a new agent user. Enterprise only. Caller must be an internal user.""" + from authentik.enterprise.license import LicenseKey + + if not LicenseKey.cached_summary().status.is_valid: + raise ValidationError(_("Enterprise is required to use this endpoint.")) + + if request.user.type != UserTypes.INTERNAL: + raise ValidationError(_("Only internal users can create agent users.")) + + requested_owner = body.validated_data.get("owner") + if requested_owner and not request.user.is_superuser: + if requested_owner.pk != request.user.pk: + raise ValidationError( + _("Non-superusers can only create agents owned by themselves.") + ) + owner = requested_owner or request.user + + username = body.validated_data["name"] + with atomic(): + try: + user: User = User.objects.create( + username=username, + name=username, + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk), + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [], + }, + path=USER_PATH_AGENT, + ) + user.set_unusable_password() + user.save() + + token = Token.objects.create( + identifier=slugify(f"agent-{username}-token"), + intent=TokenIntents.INTENT_API, + user=user, + expires=now() + timedelta(hours=24), + expiring=True, + ) + user.assign_perms_to_managed_role("authentik_core.view_token_key", token) + + owner.assign_perms_to_managed_role("authentik_core.view_user", user) + owner.assign_perms_to_managed_role("authentik_core.change_user", user) + owner.assign_perms_to_managed_role("authentik_core.delete_user", user) + owner.assign_perms_to_managed_role( + "authentik_core.view_user_applications", user + ) + + Event.new( + EventAction.MODEL_CREATED, + model=sanitize_dict(model_to_dict(user)), + agent_owner=sanitize_dict(model_to_dict(owner)), + ).from_http(request) + + return Response( + { + "username": user.username, + "user_uid": user.uid, + "user_pk": user.pk, + "token": token.key, + } + ) + except IntegrityError as exc: + error_msg = str(exc).lower() + if "unique" in error_msg: + return Response( + data={ + "non_field_errors": [ + _("A user with this username already exists") + ] + }, + status=400, + ) + else: + LOGGER.warning("Agent user creation failed", exc=exc) + return Response( + data={"non_field_errors": [_("Unable to create user")]}, + status=400, + ) + except (ValueError, TypeError) as exc: + LOGGER.error("Unexpected error during agent user creation", exc=exc) + return Response( + data={"non_field_errors": [_("Unknown error occurred")]}, + status=500, + ) + + @extend_schema( + request=UserAgentAllowedAppsSerializer, + responses={ + 200: UserAgentAllowedAppsSerializer, + 400: OpenApiResponse(description="Invalid app UUIDs or owner lacks access"), + 403: OpenApiResponse(description="Not the agent's owner or superuser"), + }, + ) + @action( + detail=True, + methods=["PUT"], + url_path="agent_allowed_apps", + url_name="agent-allowed-apps", + pagination_class=None, + filter_backends=[], + ) + @validate(UserAgentAllowedAppsSerializer) + def agent_allowed_apps( + self, request: Request, pk: int, body: UserAgentAllowedAppsSerializer + ) -> Response: + """Replace the allowed application list for an agent user. + Caller must be the agent's owner or a superuser.""" + from authentik.core.apps import AppAccessWithoutBindings + from authentik.policies.engine import PolicyEngine + + agent: User = self.get_object() + + if agent.type != UserTypes.AGENT: + return Response( + data={"non_field_errors": [_("User is not an agent user.")]}, + status=400, + ) + + owner_pk = agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + is_owner = str(request.user.pk) == owner_pk + if not request.user.is_superuser and not is_owner: + return Response(status=403) + + try: + owner = User.objects.get(pk=owner_pk) + except User.DoesNotExist: + return Response( + data={"non_field_errors": [_("Agent owner not found.")]}, + status=400, + ) + + app_uuids = body.validated_data["allowed_apps"] + errors = [] + for app_uuid in app_uuids: + try: + app = Application.objects.get(pk=app_uuid) + except Application.DoesNotExist: + errors.append(str(app_uuid)) + continue + engine = PolicyEngine(app, owner, request) + engine.empty_result = AppAccessWithoutBindings.get() + engine.use_cache = False + engine.build() + if not engine.passing: + errors.append(str(app_uuid)) + + if errors: + return Response( + data={ + "allowed_apps": [ + _( + "Owner does not have access to application %(uuid)s " + "or application does not exist." + ) + % {"uuid": uuid} + for uuid in errors + ] + }, + status=400, + ) + + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS] = [str(u) for u in app_uuids] + agent.save(update_fields=["attributes"]) + return Response({"allowed_apps": [str(u) for u in app_uuids]}) + @extend_schema(responses={200: SessionUserSerializer(many=False)}) @action( url_path="me", diff --git a/authentik/core/management/commands/change_user_type.py b/authentik/core/management/commands/change_user_type.py index 2fbfff4c52fb..d960235f1d4a 100644 --- a/authentik/core/management/commands/change_user_type.py +++ b/authentik/core/management/commands/change_user_type.py @@ -18,6 +18,7 @@ def handle_per_tenant(self, **options): User.objects.exclude_anonymous() .exclude(type=UserTypes.SERVICE_ACCOUNT) .exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT) + .exclude(type=UserTypes.AGENT) ) if options["usernames"] and options["all"]: self.stderr.write("--all and usernames specified, only one can be specified") diff --git a/authentik/core/migrations/0058_user_agent_type.py b/authentik/core/migrations/0058_user_agent_type.py new file mode 100644 index 000000000000..02532fd3f30e --- /dev/null +++ b/authentik/core/migrations/0058_user_agent_type.py @@ -0,0 +1,42 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="type", + field=models.CharField( + choices=[ + ("internal", "Internal"), + ("external", "External"), + ("service_account", "Service Account"), + ("internal_service_account", "Internal Service Account"), + ("agent", "Agent"), + ], + default="internal", + max_length=100, + ), + ), + migrations.AlterModelOptions( + name="user", + options={ + "permissions": [ + ("reset_user_password", "Reset Password"), + ("impersonate", "Can impersonate other users"), + ("preview_user", "Can preview user data sent to providers"), + ("view_user_applications", "View applications the user has access to"), + ("add_agent_user", "Can create agent users"), + ], + "verbose_name": "User", + "verbose_name_plural": "Users", + }, + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 77c759fbba0a..ae5fde1e3c31 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -67,6 +67,10 @@ USER_ATTRIBUTE_CHANGE_NAME = f"{_USER_ATTR_PREFIX}/can-change-name" USER_ATTRIBUTE_CHANGE_EMAIL = f"{_USER_ATTR_PREFIX}/can-change-email" USER_PATH_SERVICE_ACCOUNT = f"{USER_PATH_SYSTEM_PREFIX}/service-accounts" +_USER_ATTR_AGENT_PREFIX = f"{USER_PATH_SYSTEM_PREFIX}/agent" +USER_ATTRIBUTE_AGENT_OWNER_PK = f"{_USER_ATTR_AGENT_PREFIX}/owner-pk" +USER_ATTRIBUTE_AGENT_ALLOWED_APPS = f"{_USER_ATTR_AGENT_PREFIX}/allowed-apps" +USER_PATH_AGENT = f"{USER_PATH_SYSTEM_PREFIX}/agents" options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( # used_by API that allows models to specify if they shadow an object @@ -126,6 +130,9 @@ class UserTypes(models.TextChoices): # accounts, such as outpost users INTERNAL_SERVICE_ACCOUNT = "internal_service_account" + # Enterprise-gated agent users owned by an internal user + AGENT = "agent" + class AttributesMixin(models.Model): """Adds an attributes property to a model""" @@ -385,6 +392,7 @@ class Meta: ("impersonate", _("Can impersonate other users")), ("preview_user", _("Can preview user data sent to providers")), ("view_user_applications", _("View applications the user has access to")), + ("add_agent_user", _("Can create agent users")), ] indexes = [ models.Index(fields=["last_login"]), diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 05cfaf2eb347..9bef0fe75c6f 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -11,12 +11,14 @@ from structlog.stdlib import get_logger from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, Application, AuthenticatedSession, BackchannelProvider, ExpiringModel, Session, User, + UserTypes, default_token_duration, ) from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication @@ -69,6 +71,35 @@ def authenticated_session_delete(sender: type[Model], instance: AuthenticatedSes Session.objects.filter(session_key=instance.pk).delete() +def _agent_qs_for_owner(owner_pk: int): + """Return a queryset of agent users belonging to the given owner pk""" + return User.objects.filter( + type=UserTypes.AGENT, + attributes__contains={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner_pk)}, + ) + + +@receiver(post_delete, sender=User) +def user_delete_cascade_agents(sender: type[Model], instance: User, **_): + """Delete agent users when their owner is deleted""" + _agent_qs_for_owner(instance.pk).delete() + + +@receiver(post_save, sender=User) +def user_save_propagate_agent_active( + sender: type[Model], instance: User, update_fields: frozenset[str] | None = None, **_ +): + """Propagate is_active changes to owned agent users""" + if update_fields is not None and "is_active" not in update_fields: + return + agents = _agent_qs_for_owner(instance.pk) + if not instance.is_active: + Session.objects.filter( + authenticatedsession__user__in=agents.filter(is_active=True) + ).delete() + agents.update(is_active=instance.is_active) + + @receiver(pre_save) def backchannel_provider_pre_save(sender: type[Model], instance: Model, **_): """Ensure backchannel providers have is_backchannel set to true""" diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py index 5354ecae84d4..75a579cf8a97 100644 --- a/authentik/core/tests/test_token_api.py +++ b/authentik/core/tests/test_token_api.py @@ -199,6 +199,50 @@ def test_list_with_permission(self): self.assertEqual(body["results"][0]["identifier"], token_should.identifier) self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier) + def test_token_rotate_by_owner(self): + """Token owner can rotate their own token""" + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=self.user, + expiring=True, + ) + original_key = token.key + response = self.client.post( + reverse("authentik_api:token-rotate", kwargs={"identifier": token.identifier}), + ) + self.assertEqual(response.status_code, 200) + token.refresh_from_db() + self.assertNotEqual(token.key, original_key) + self.assertEqual(token.key, loads(response.content)["key"]) + + def test_token_rotate_by_superuser(self): + """Superuser can rotate any token""" + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=self.user, + expiring=True, + ) + self.client.force_login(self.admin) + response = self.client.post( + reverse("authentik_api:token-rotate", kwargs={"identifier": token.identifier}), + ) + self.assertEqual(response.status_code, 200) + + def test_token_rotate_unauthorized(self): + """Non-owner cannot rotate another user's token""" + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=self.admin, + expiring=True, + ) + response = self.client.post( + reverse("authentik_api:token-rotate", kwargs={"identifier": token.identifier}), + ) + self.assertEqual(response.status_code, 403) + def test_serializer_no_request(self): """Test serializer without request""" self.assertTrue( diff --git a/authentik/core/tests/test_users.py b/authentik/core/tests/test_users.py index 8900058e4871..938534e49eee 100644 --- a/authentik/core/tests/test_users.py +++ b/authentik/core/tests/test_users.py @@ -2,7 +2,14 @@ from django.test.testcases import TestCase -from authentik.core.models import User +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, + USER_PATH_AGENT, + AuthenticatedSession, + Session, + User, + UserTypes, +) from authentik.events.models import Event from authentik.lib.generators import generate_id @@ -33,3 +40,89 @@ def test_user_ak_groups_event(self): self.assertEqual(Event.objects.count(), 1) user.ak_groups.all() self.assertEqual(Event.objects.count(), 1) + + +class TestAgentUserSignals(TestCase): + """Test signals related to agent user lifecycle""" + + def _create_owner(self): + owner = User.objects.create(username=generate_id()) + owner.set_unusable_password() + owner.save() + return owner + + def _create_agent(self, owner): + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)}, + path=USER_PATH_AGENT, + ) + agent.set_unusable_password() + agent.save() + return agent + + def test_delete_owner_cascades_to_agents(self): + """Deleting an owner also deletes all their agent users""" + owner = self._create_owner() + agent1 = self._create_agent(owner) + agent2 = self._create_agent(owner) + other_owner = self._create_owner() + other_agent = self._create_agent(other_owner) + + owner.delete() + + self.assertFalse(User.objects.filter(pk=agent1.pk).exists()) + self.assertFalse(User.objects.filter(pk=agent2.pk).exists()) + self.assertTrue(User.objects.filter(pk=other_agent.pk).exists()) + + def test_deactivate_owner_deactivates_agents(self): + """Setting an owner inactive also marks all their agents inactive""" + owner = self._create_owner() + agent = self._create_agent(owner) + + owner.is_active = False + owner.save(update_fields=["is_active"]) + + agent.refresh_from_db() + self.assertFalse(agent.is_active) + + def test_reactivate_owner_reactivates_agents(self): + """Setting an owner active again also re-activates their agents""" + owner = self._create_owner() + owner.is_active = False + owner.save(update_fields=["is_active"]) + agent = self._create_agent(owner) + agent.is_active = False + agent.save(update_fields=["is_active"]) + + owner.is_active = True + owner.save(update_fields=["is_active"]) + + agent.refresh_from_db() + self.assertTrue(agent.is_active) + + def test_unrelated_owner_save_does_not_affect_agents(self): + """Saving an owner without changing is_active does not touch agents""" + owner = self._create_owner() + agent = self._create_agent(owner) + agent.is_active = False + agent.save(update_fields=["is_active"]) + + owner.name = generate_id() + owner.save(update_fields=["name"]) + + agent.refresh_from_db() + self.assertFalse(agent.is_active) + + def test_deactivate_owner_clears_agent_sessions(self): + """Deactivating an owner removes authenticated sessions for their agents""" + owner = self._create_owner() + agent = self._create_agent(owner) + session = Session.objects.create(session_key=generate_id(), session_data="{}") + AuthenticatedSession.objects.create(user=agent, session=session) + + owner.is_active = False + owner.save(update_fields=["is_active"]) + + self.assertFalse(Session.objects.filter(pk=session.pk).exists()) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index ef7069827f15..8095e011ed48 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from json import loads +from unittest.mock import MagicMock, patch from django.urls.base import reverse from django.utils.timezone import now @@ -9,10 +10,15 @@ from authentik.brands.models import Brand from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, USER_ATTRIBUTE_TOKEN_EXPIRING, + USER_PATH_AGENT, + Application, AuthenticatedSession, Session, Token, + TokenIntents, User, UserTypes, ) @@ -878,3 +884,173 @@ def test_sort_by_last_login(self): self.assertIn(user2.pk, pks) # Verify user2 comes before user1 in descending order self.assertLess(pks.index(user2.pk), pks.index(user1.pk)) + + +class TestAgentUserAPI(APITestCase): + """Test agent user API""" + + def setUp(self) -> None: + self.admin = create_test_admin_user() + self.admin.assign_perms_to_managed_role("authentik_core.add_agent_user") + self.user = create_test_user() + + def _create_agent(self, name="test-agent", owner=None): + owner = owner or self.admin + agent = User.objects.create( + username=name, + name=name, + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk), + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [], + }, + path=USER_PATH_AGENT, + ) + agent.set_unusable_password() + agent.save() + return agent + + def test_agent_create(self): + """Agent user creation""" + self.client.force_login(self.admin) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "test-agent"}, + ) + self.assertEqual(response.status_code, 200) + agent = User.objects.get(username="test-agent") + self.assertEqual(agent.type, UserTypes.AGENT) + self.assertEqual(agent.path, USER_PATH_AGENT) + self.assertEqual(agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK), str(self.admin.pk)) + self.assertEqual(agent.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS), []) + self.assertFalse(agent.has_usable_password()) + token = Token.objects.filter(user=agent, intent=TokenIntents.INTENT_API).first() + self.assertIsNotNone(token) + self.assertTrue(token.expiring) + + def test_agent_create_no_license(self): + """Agent creation is rejected without a valid enterprise license""" + self.client.force_login(self.admin) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=False))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "test-agent"}, + ) + self.assertEqual(response.status_code, 400) + + def test_agent_create_non_internal_user(self): + """Only internal users can create agent users""" + external = create_test_user() + external.type = UserTypes.EXTERNAL + external.save() + external.assign_perms_to_managed_role("authentik_core.add_agent_user") + self.client.force_login(external) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "test-agent"}, + ) + self.assertEqual(response.status_code, 400) + + def test_agent_create_duplicate(self): + """Duplicate agent username returns a user-friendly error""" + self._create_agent("test-agent-dup") + self.client.force_login(self.admin) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "test-agent-dup"}, + ) + self.assertEqual(response.status_code, 400) + + def test_agent_type_cannot_be_changed(self): + """Agent user type cannot be changed via the users API""" + agent = self._create_agent() + self.client.force_login(self.admin) + response = self.client.patch( + reverse("authentik_api:user-detail", kwargs={"pk": agent.pk}), + data={"type": UserTypes.INTERNAL}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_agent_owner_cannot_be_changed(self): + """Agent owner cannot be changed via the users API""" + agent = self._create_agent() + other = create_test_user() + self.client.force_login(self.admin) + new_attrs = dict(agent.attributes) + new_attrs[USER_ATTRIBUTE_AGENT_OWNER_PK] = str(other.pk) + response = self.client.patch( + reverse("authentik_api:user-detail", kwargs={"pk": agent.pk}), + data={"attributes": new_attrs}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_agent_allowed_apps_update(self): + """Owner can update the agent's allowed apps list""" + agent = self._create_agent(owner=self.admin) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + self.client.force_login(self.admin) + response = self.client.put( + reverse("authentik_api:user-agent-allowed-apps", kwargs={"pk": agent.pk}), + data={"allowed_apps": [str(app.pk)]}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + agent.refresh_from_db() + self.assertIn(str(app.pk), agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS]) + + def test_agent_allowed_apps_update_unauthorized(self): + """Non-owner cannot update the agent's allowed apps list""" + other = create_test_user() + agent = self._create_agent(owner=other) + self.client.force_login(self.admin) + response = self.client.put( + reverse("authentik_api:user-agent-allowed-apps", kwargs={"pk": agent.pk}), + data={"allowed_apps": []}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + def test_agent_allowed_apps_update_non_agent(self): + """Endpoint rejects non-agent users""" + self.client.force_login(self.admin) + response = self.client.put( + reverse("authentik_api:user-agent-allowed-apps", kwargs={"pk": self.user.pk}), + data={"allowed_apps": []}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_token_rotate_by_agent_owner(self): + """Agent owner can rotate the agent's token""" + agent = self._create_agent(owner=self.user) + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=agent, + expiring=True, + ) + original_key = token.key + self.client.force_login(self.user) + response = self.client.post( + reverse("authentik_api:token-rotate", kwargs={"identifier": token.identifier}), + ) + self.assertEqual(response.status_code, 200) + token.refresh_from_db() + self.assertNotEqual(token.key, original_key) diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py index ac0c6893fd9b..b15f77b63852 100644 --- a/authentik/core/views/interface.py +++ b/authentik/core/views/interface.py @@ -30,6 +30,7 @@ def redirect_to_app(self, request: HttpRequest): UserTypes.EXTERNAL, UserTypes.SERVICE_ACCOUNT, UserTypes.INTERNAL_SERVICE_ACCOUNT, + UserTypes.AGENT, ): brand: Brand = request.brand if brand.default_application: @@ -70,6 +71,7 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespo UserTypes.EXTERNAL, UserTypes.SERVICE_ACCOUNT, UserTypes.INTERNAL_SERVICE_ACCOUNT, + UserTypes.AGENT, ): brand: Brand = request.brand if brand.default_application: diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index ca30dacc050c..0d5681974d55 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -141,8 +141,10 @@ def get_object_qs(self, type: type[User | Group], **kwargs) -> QuerySet[User | G # according to the provider's settings base = User.objects.all().exclude_anonymous().filter(**kwargs) if self.exclude_users_service_account: - base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( - type=UserTypes.INTERNAL_SERVICE_ACCOUNT + base = ( + base.exclude(type=UserTypes.SERVICE_ACCOUNT) + .exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT) + .exclude(type=UserTypes.AGENT) ) if self.filter_group: base = base.filter(groups__in=[self.filter_group]) diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 19eae7eb3508..f680a7d6d36e 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -130,8 +130,10 @@ def get_object_qs(self, type: type[User | Group], **kwargs) -> QuerySet[User | G # according to the provider's settings base = User.objects.all().exclude_anonymous().filter(**kwargs) if self.exclude_users_service_account: - base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( - type=UserTypes.INTERNAL_SERVICE_ACCOUNT + base = ( + base.exclude(type=UserTypes.SERVICE_ACCOUNT) + .exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT) + .exclude(type=UserTypes.AGENT) ) if self.filter_group: base = base.filter(groups__in=[self.filter_group]) diff --git a/authentik/enterprise/tasks.py b/authentik/enterprise/tasks.py index 7c5a3bbea0a2..7c849f0fff89 100644 --- a/authentik/enterprise/tasks.py +++ b/authentik/enterprise/tasks.py @@ -6,6 +6,18 @@ from authentik.enterprise.license import LicenseKey +def _deactivate_agent_users(): + """Mark all active agent users inactive and remove their sessions when the enterprise + license is not valid. Called after each license usage recording.""" + from authentik.core.models import Session, User, UserTypes + + agents = User.objects.filter(type=UserTypes.AGENT, is_active=True) + Session.objects.filter(authenticatedsession__user__in=agents).delete() + agents.update(is_active=False) + + @actor(description=_("Update enterprise license status.")) def enterprise_update_usage(): - LicenseKey.get_total().record_usage() + usage = LicenseKey.get_total().record_usage() + if not usage.status.is_valid: + _deactivate_agent_users() diff --git a/authentik/enterprise/tests/test_tasks.py b/authentik/enterprise/tests/test_tasks.py new file mode 100644 index 000000000000..213f5e95e980 --- /dev/null +++ b/authentik/enterprise/tests/test_tasks.py @@ -0,0 +1,64 @@ +"""Enterprise task tests""" + +from django.test import TestCase + +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, + USER_PATH_AGENT, + User, + UserTypes, +) +from authentik.lib.generators import generate_id + + +class TestDeactivateAgentUsers(TestCase): + """Tests for _deactivate_agent_users enterprise task""" + + def _create_agent(self, owner): + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)}, + path=USER_PATH_AGENT, + is_active=True, + ) + agent.set_unusable_password() + agent.save() + return agent + + def test_deactivates_all_active_agents(self): + """_deactivate_agent_users marks all active agent users inactive""" + from authentik.enterprise.tasks import _deactivate_agent_users + + owner = User.objects.create(username=generate_id()) + agent1 = self._create_agent(owner) + agent2 = self._create_agent(owner) + + _deactivate_agent_users() + + agent1.refresh_from_db() + agent2.refresh_from_db() + self.assertFalse(agent1.is_active) + self.assertFalse(agent2.is_active) + + def test_does_not_deactivate_non_agents(self): + """_deactivate_agent_users does not affect non-agent service accounts""" + from authentik.enterprise.tasks import _deactivate_agent_users + + sa = User.objects.create( + username=generate_id(), + type=UserTypes.SERVICE_ACCOUNT, + is_active=True, + ) + internal = User.objects.create( + username=generate_id(), + type=UserTypes.INTERNAL, + is_active=True, + ) + + _deactivate_agent_users() + + sa.refresh_from_db() + internal.refresh_from_db() + self.assertTrue(sa.is_active) + self.assertTrue(internal.is_active) diff --git a/authentik/events/utils.py b/authentik/events/utils.py index 526e7cd9b68c..1e3cbc83a44e 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -31,7 +31,7 @@ # Special keys which are *not* cleaned, even when the default filter # is matched ALLOWED_SPECIAL_KEYS = re.compile( - r"passing|password_change_date|^auth_method(_args)?$", + r"passing|password_change_date|^auth_method(_args)?$|^goauthentik\.io/agent/", flags=re.I, ) diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py index 1561ed2aa42c..fa43cdb1a8ce 100644 --- a/authentik/policies/expression/evaluator.py +++ b/authentik/policies/expression/evaluator.py @@ -50,6 +50,12 @@ def set_policy_request(self, request: PolicyRequest): self._context["ak_client_ip"] = ip_address( request.obj.client_ip or ClientIPMiddleware.default_ip ) + from authentik.core.models import Application # noqa: PLC0415 + + if request.obj and isinstance(request.obj, Application): + self._context["has_access_to_application"] = self._make_has_access_to_application( + request + ) def set_http_request(self, request: HttpRequest): """Update context based on http request""" @@ -58,6 +64,46 @@ def set_http_request(self, request: HttpRequest): self._context["ak_client_ip"] = ip_address(ClientIPMiddleware.get_client_ip(request)) self._context["http_request"] = request + def _make_has_access_to_application(self, request: PolicyRequest): + """Return a no-argument callable that checks whether the current agent user's owner + has access to the application currently being evaluated (request.obj).""" + + def has_access_to_application() -> bool: + from authentik.core.apps import AppAccessWithoutBindings + from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, + User, + UserTypes, + ) + from authentik.policies.engine import PolicyEngine + + user = request.user + app = request.obj + + if user.type != UserTypes.AGENT: + return False + + owner_pk = user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + if not owner_pk: + return False + + allowed_apps = user.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS, []) + if str(app.pk) not in allowed_apps: + return False + + owner = User.objects.filter(pk=owner_pk).first() + if not owner: + return False + + engine = PolicyEngine(app, owner) + engine.empty_result = AppAccessWithoutBindings.get() + engine.use_cache = False + engine.build() + return engine.passing + + return has_access_to_application + def handle_error(self, exc: Exception, expression_source: str): """Exception Handler""" raise PolicyException(exc) diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py index ed8fac2c6535..55b756707ed3 100644 --- a/authentik/policies/expression/tests.py +++ b/authentik/policies/expression/tests.py @@ -5,7 +5,14 @@ from rest_framework.serializers import ValidationError from rest_framework.test import APITestCase -from authentik.core.models import Application +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, + USER_PATH_AGENT, + Application, + User, + UserTypes, +) from authentik.lib.generators import generate_id from authentik.policies.exceptions import PolicyException from authentik.policies.expression.api import ExpressionPolicySerializer @@ -135,6 +142,84 @@ def test_call_policy_test_like(self): self.assertEqual(res.messages, ("/", "/", "/")) +class TestHasAccessToApplication(TestCase): + """Tests for has_access_to_application policy context helper""" + + def setUp(self): + self.factory = RequestFactory() + self.app = Application.objects.create(name=generate_id(), slug=generate_id()) + self.owner = User.objects.create(username=generate_id()) + + def _create_agent(self, allowed_apps=None): + return User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: str(self.owner.pk), + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: allowed_apps if allowed_apps is not None else [], + }, + path=USER_PATH_AGENT, + ) + + def _evaluator_with(self, user, obj=None): + request = PolicyRequest(user=user) + request.obj = obj or self.app + request.http_request = self.factory.get("/") + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(request) + return evaluator + + def test_not_injected_for_non_application_obj(self): + """has_access_to_application is not injected when obj is not an Application""" + agent = self._create_agent(allowed_apps=[str(self.app.pk)]) + request = PolicyRequest(user=agent) + request.obj = None + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(request) + self.assertNotIn("has_access_to_application", evaluator._context) + + def test_injected_for_application_obj(self): + """has_access_to_application is injected when obj is an Application""" + agent = self._create_agent(allowed_apps=[str(self.app.pk)]) + evaluator = self._evaluator_with(agent) + self.assertIn("has_access_to_application", evaluator._context) + + def test_non_agent_returns_false(self): + """Returns False when the current user is not an agent""" + evaluator = self._evaluator_with(self.owner) + result = evaluator._context["has_access_to_application"]() + self.assertFalse(result) + + def test_app_not_in_allowed_list_returns_false(self): + """Returns False when the application is not in the agent's allowed apps list""" + agent = self._create_agent(allowed_apps=[]) + evaluator = self._evaluator_with(agent) + result = evaluator._context["has_access_to_application"]() + self.assertFalse(result) + + def test_missing_owner_returns_false(self): + """Returns False when the owner pk points to a non-existent user""" + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: "999999", + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [str(self.app.pk)], + }, + path=USER_PATH_AGENT, + ) + evaluator = self._evaluator_with(agent) + result = evaluator._context["has_access_to_application"]() + self.assertFalse(result) + + def test_no_attributes_returns_false(self): + """Returns False when the user has no attributes""" + user = get_anonymous_user() + evaluator = self._evaluator_with(user) + result = evaluator._context["has_access_to_application"]() + self.assertFalse(result) + + class TestExpressionPolicyAPI(APITestCase): """Test expression policy's API""" diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 025b5db1b8a0..8c0bcd9513d3 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -188,8 +188,10 @@ def get_object_qs(self, type: type[User | Group], **kwargs) -> QuerySet[User | G # according to the provider's settings base = User.objects.all().exclude_anonymous().filter(**kwargs) if self.exclude_users_service_account: - base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( - type=UserTypes.INTERNAL_SERVICE_ACCOUNT + base = ( + base.exclude(type=UserTypes.SERVICE_ACCOUNT) + .exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT) + .exclude(type=UserTypes.AGENT) ) # Filter users by their access to the backchannel application if an application is set diff --git a/blueprints/schema.json b/blueprints/schema.json index 663af89beefe..1c22cf26b3c5 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -5520,7 +5520,8 @@ "internal", "external", "service_account", - "internal_service_account" + "internal_service_account", + "agent" ], "title": "Type" }, @@ -5545,6 +5546,7 @@ "authentik_brands.change_brand", "authentik_brands.delete_brand", "authentik_brands.view_brand", + "authentik_core.add_agent_user", "authentik_core.add_application", "authentik_core.add_applicationentitlement", "authentik_core.add_authenticatedsession", @@ -6257,6 +6259,7 @@ "permission": { "type": "string", "enum": [ + "add_agent_user", "add_user", "change_user", "delete_user", @@ -11212,6 +11215,7 @@ "authentik_brands.change_brand", "authentik_brands.delete_brand", "authentik_brands.view_brand", + "authentik_core.add_agent_user", "authentik_core.add_application", "authentik_core.add_applicationentitlement", "authentik_core.add_authenticatedsession", @@ -15976,7 +15980,8 @@ "internal", "external", "service_account", - "internal_service_account" + "internal_service_account", + "agent" ], "title": "User type" }, diff --git a/packages/client-go/api_core.go b/packages/client-go/api_core.go index bd05db13cef4..5036e4ee65de 100644 --- a/packages/client-go/api_core.go +++ b/packages/client-go/api_core.go @@ -6066,6 +6066,121 @@ func (a *CoreAPIService) CoreTokensRetrieveExecute(r ApiCoreTokensRetrieveReques return localVarReturnValue, localVarHTTPResponse, nil } +type ApiCoreTokensRotateCreateRequest struct { + ctx context.Context + ApiService *CoreAPIService + identifier string +} + +func (r ApiCoreTokensRotateCreateRequest) Execute() (*TokenView, *http.Response, error) { + return r.ApiService.CoreTokensRotateCreateExecute(r) +} + +/* +CoreTokensRotateCreate Method for CoreTokensRotateCreate + +Rotate the token key and reset the expiry to 24 hours. Only callable by the token +owner, the owning agent's human owner, or a superuser. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param identifier + @return ApiCoreTokensRotateCreateRequest +*/ +func (a *CoreAPIService) CoreTokensRotateCreate(ctx context.Context, identifier string) ApiCoreTokensRotateCreateRequest { + return ApiCoreTokensRotateCreateRequest{ + ApiService: a, + ctx: ctx, + identifier: identifier, + } +} + +// Execute executes the request +// +// @return TokenView +func (a *CoreAPIService) CoreTokensRotateCreateExecute(r ApiCoreTokensRotateCreateRequest) (*TokenView, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *TokenView + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreTokensRotateCreate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/tokens/{identifier}/rotate/" + localVarPath = strings.Replace(localVarPath, "{"+"identifier"+"}", url.PathEscape(parameterValueToString(r.identifier, "identifier")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiCoreTokensSetKeyCreateRequest struct { ctx context.Context ApiService *CoreAPIService @@ -7240,6 +7355,254 @@ func (a *CoreAPIService) CoreUserConsentUsedByListExecute(r ApiCoreUserConsentUs return localVarReturnValue, localVarHTTPResponse, nil } +type ApiCoreUsersAgentAllowedAppsUpdateRequest struct { + ctx context.Context + ApiService *CoreAPIService + id int32 + userAgentAllowedAppsRequest *UserAgentAllowedAppsRequest +} + +func (r ApiCoreUsersAgentAllowedAppsUpdateRequest) UserAgentAllowedAppsRequest(userAgentAllowedAppsRequest UserAgentAllowedAppsRequest) ApiCoreUsersAgentAllowedAppsUpdateRequest { + r.userAgentAllowedAppsRequest = &userAgentAllowedAppsRequest + return r +} + +func (r ApiCoreUsersAgentAllowedAppsUpdateRequest) Execute() (*UserAgentAllowedApps, *http.Response, error) { + return r.ApiService.CoreUsersAgentAllowedAppsUpdateExecute(r) +} + +/* +CoreUsersAgentAllowedAppsUpdate Method for CoreUsersAgentAllowedAppsUpdate + +Replace the allowed application list for an agent user. +Caller must be the agent's owner or a superuser. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id A unique integer value identifying this User. + @return ApiCoreUsersAgentAllowedAppsUpdateRequest +*/ +func (a *CoreAPIService) CoreUsersAgentAllowedAppsUpdate(ctx context.Context, id int32) ApiCoreUsersAgentAllowedAppsUpdateRequest { + return ApiCoreUsersAgentAllowedAppsUpdateRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return UserAgentAllowedApps +func (a *CoreAPIService) CoreUsersAgentAllowedAppsUpdateExecute(r ApiCoreUsersAgentAllowedAppsUpdateRequest) (*UserAgentAllowedApps, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPut + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UserAgentAllowedApps + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreUsersAgentAllowedAppsUpdate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/users/{id}/agent_allowed_apps/" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.userAgentAllowedAppsRequest == nil { + return localVarReturnValue, nil, reportError("userAgentAllowedAppsRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.userAgentAllowedAppsRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiCoreUsersAgentCreateRequest struct { + ctx context.Context + ApiService *CoreAPIService + userAgentRequest *UserAgentRequest +} + +func (r ApiCoreUsersAgentCreateRequest) UserAgentRequest(userAgentRequest UserAgentRequest) ApiCoreUsersAgentCreateRequest { + r.userAgentRequest = &userAgentRequest + return r +} + +func (r ApiCoreUsersAgentCreateRequest) Execute() (*UserAgentResponse, *http.Response, error) { + return r.ApiService.CoreUsersAgentCreateExecute(r) +} + +/* +CoreUsersAgentCreate Method for CoreUsersAgentCreate + +Create a new agent user. Enterprise only. Caller must be an internal user. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiCoreUsersAgentCreateRequest +*/ +func (a *CoreAPIService) CoreUsersAgentCreate(ctx context.Context) ApiCoreUsersAgentCreateRequest { + return ApiCoreUsersAgentCreateRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return UserAgentResponse +func (a *CoreAPIService) CoreUsersAgentCreateExecute(r ApiCoreUsersAgentCreateRequest) (*UserAgentResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UserAgentResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreUsersAgentCreate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/users/agent/" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.userAgentRequest == nil { + return localVarReturnValue, nil, reportError("userAgentRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.userAgentRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v GenericError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiCoreUsersCreateRequest struct { ctx context.Context ApiService *CoreAPIService diff --git a/packages/client-go/model_user_agent_allowed_apps.go b/packages/client-go/model_user_agent_allowed_apps.go new file mode 100644 index 000000000000..79a9951b638f --- /dev/null +++ b/packages/client-go/model_user_agent_allowed_apps.go @@ -0,0 +1,167 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// checks if the UserAgentAllowedApps type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserAgentAllowedApps{} + +// UserAgentAllowedApps Payload to update an agent's allowed applications +type UserAgentAllowedApps struct { + AllowedApps []string `json:"allowed_apps"` + AdditionalProperties map[string]interface{} +} + +type _UserAgentAllowedApps UserAgentAllowedApps + +// NewUserAgentAllowedApps instantiates a new UserAgentAllowedApps object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserAgentAllowedApps(allowedApps []string) *UserAgentAllowedApps { + this := UserAgentAllowedApps{} + this.AllowedApps = allowedApps + return &this +} + +// NewUserAgentAllowedAppsWithDefaults instantiates a new UserAgentAllowedApps object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserAgentAllowedAppsWithDefaults() *UserAgentAllowedApps { + this := UserAgentAllowedApps{} + return &this +} + +// GetAllowedApps returns the AllowedApps field value +func (o *UserAgentAllowedApps) GetAllowedApps() []string { + if o == nil { + var ret []string + return ret + } + + return o.AllowedApps +} + +// GetAllowedAppsOk returns a tuple with the AllowedApps field value +// and a boolean to check if the value has been set. +func (o *UserAgentAllowedApps) GetAllowedAppsOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.AllowedApps, true +} + +// SetAllowedApps sets field value +func (o *UserAgentAllowedApps) SetAllowedApps(v []string) { + o.AllowedApps = v +} + +func (o UserAgentAllowedApps) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserAgentAllowedApps) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["allowed_apps"] = o.AllowedApps + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *UserAgentAllowedApps) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "allowed_apps", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserAgentAllowedApps := _UserAgentAllowedApps{} + + err = json.Unmarshal(data, &varUserAgentAllowedApps) + + if err != nil { + return err + } + + *o = UserAgentAllowedApps(varUserAgentAllowedApps) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "allowed_apps") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableUserAgentAllowedApps struct { + value *UserAgentAllowedApps + isSet bool +} + +func (v NullableUserAgentAllowedApps) Get() *UserAgentAllowedApps { + return v.value +} + +func (v *NullableUserAgentAllowedApps) Set(val *UserAgentAllowedApps) { + v.value = val + v.isSet = true +} + +func (v NullableUserAgentAllowedApps) IsSet() bool { + return v.isSet +} + +func (v *NullableUserAgentAllowedApps) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserAgentAllowedApps(val *UserAgentAllowedApps) *NullableUserAgentAllowedApps { + return &NullableUserAgentAllowedApps{value: val, isSet: true} +} + +func (v NullableUserAgentAllowedApps) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserAgentAllowedApps) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_agent_allowed_apps_request.go b/packages/client-go/model_user_agent_allowed_apps_request.go new file mode 100644 index 000000000000..93c05199e27f --- /dev/null +++ b/packages/client-go/model_user_agent_allowed_apps_request.go @@ -0,0 +1,167 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// checks if the UserAgentAllowedAppsRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserAgentAllowedAppsRequest{} + +// UserAgentAllowedAppsRequest Payload to update an agent's allowed applications +type UserAgentAllowedAppsRequest struct { + AllowedApps []string `json:"allowed_apps"` + AdditionalProperties map[string]interface{} +} + +type _UserAgentAllowedAppsRequest UserAgentAllowedAppsRequest + +// NewUserAgentAllowedAppsRequest instantiates a new UserAgentAllowedAppsRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserAgentAllowedAppsRequest(allowedApps []string) *UserAgentAllowedAppsRequest { + this := UserAgentAllowedAppsRequest{} + this.AllowedApps = allowedApps + return &this +} + +// NewUserAgentAllowedAppsRequestWithDefaults instantiates a new UserAgentAllowedAppsRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserAgentAllowedAppsRequestWithDefaults() *UserAgentAllowedAppsRequest { + this := UserAgentAllowedAppsRequest{} + return &this +} + +// GetAllowedApps returns the AllowedApps field value +func (o *UserAgentAllowedAppsRequest) GetAllowedApps() []string { + if o == nil { + var ret []string + return ret + } + + return o.AllowedApps +} + +// GetAllowedAppsOk returns a tuple with the AllowedApps field value +// and a boolean to check if the value has been set. +func (o *UserAgentAllowedAppsRequest) GetAllowedAppsOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.AllowedApps, true +} + +// SetAllowedApps sets field value +func (o *UserAgentAllowedAppsRequest) SetAllowedApps(v []string) { + o.AllowedApps = v +} + +func (o UserAgentAllowedAppsRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserAgentAllowedAppsRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["allowed_apps"] = o.AllowedApps + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *UserAgentAllowedAppsRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "allowed_apps", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserAgentAllowedAppsRequest := _UserAgentAllowedAppsRequest{} + + err = json.Unmarshal(data, &varUserAgentAllowedAppsRequest) + + if err != nil { + return err + } + + *o = UserAgentAllowedAppsRequest(varUserAgentAllowedAppsRequest) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "allowed_apps") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableUserAgentAllowedAppsRequest struct { + value *UserAgentAllowedAppsRequest + isSet bool +} + +func (v NullableUserAgentAllowedAppsRequest) Get() *UserAgentAllowedAppsRequest { + return v.value +} + +func (v *NullableUserAgentAllowedAppsRequest) Set(val *UserAgentAllowedAppsRequest) { + v.value = val + v.isSet = true +} + +func (v NullableUserAgentAllowedAppsRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableUserAgentAllowedAppsRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserAgentAllowedAppsRequest(val *UserAgentAllowedAppsRequest) *NullableUserAgentAllowedAppsRequest { + return &NullableUserAgentAllowedAppsRequest{value: val, isSet: true} +} + +func (v NullableUserAgentAllowedAppsRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserAgentAllowedAppsRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_agent_request.go b/packages/client-go/model_user_agent_request.go new file mode 100644 index 000000000000..c72ca6cd500e --- /dev/null +++ b/packages/client-go/model_user_agent_request.go @@ -0,0 +1,204 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// checks if the UserAgentRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserAgentRequest{} + +// UserAgentRequest Payload to create an agent user +type UserAgentRequest struct { + Name string `json:"name"` + Owner *int32 `json:"owner,omitempty"` + AdditionalProperties map[string]interface{} +} + +type _UserAgentRequest UserAgentRequest + +// NewUserAgentRequest instantiates a new UserAgentRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserAgentRequest(name string) *UserAgentRequest { + this := UserAgentRequest{} + this.Name = name + return &this +} + +// NewUserAgentRequestWithDefaults instantiates a new UserAgentRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserAgentRequestWithDefaults() *UserAgentRequest { + this := UserAgentRequest{} + return &this +} + +// GetName returns the Name field value +func (o *UserAgentRequest) GetName() string { + if o == nil { + var ret string + return ret + } + + return o.Name +} + +// GetNameOk returns a tuple with the Name field value +// and a boolean to check if the value has been set. +func (o *UserAgentRequest) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Name, true +} + +// SetName sets field value +func (o *UserAgentRequest) SetName(v string) { + o.Name = v +} + +// GetOwner returns the Owner field value if set, zero value otherwise. +func (o *UserAgentRequest) GetOwner() int32 { + if o == nil || IsNil(o.Owner) { + var ret int32 + return ret + } + return *o.Owner +} + +// GetOwnerOk returns a tuple with the Owner field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserAgentRequest) GetOwnerOk() (*int32, bool) { + if o == nil || IsNil(o.Owner) { + return nil, false + } + return o.Owner, true +} + +// HasOwner returns a boolean if a field has been set. +func (o *UserAgentRequest) HasOwner() bool { + if o != nil && !IsNil(o.Owner) { + return true + } + + return false +} + +// SetOwner gets a reference to the given int32 and assigns it to the Owner field. +func (o *UserAgentRequest) SetOwner(v int32) { + o.Owner = &v +} + +func (o UserAgentRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserAgentRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["name"] = o.Name + if !IsNil(o.Owner) { + toSerialize["owner"] = o.Owner + } + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *UserAgentRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "name", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserAgentRequest := _UserAgentRequest{} + + err = json.Unmarshal(data, &varUserAgentRequest) + + if err != nil { + return err + } + + *o = UserAgentRequest(varUserAgentRequest) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "name") + delete(additionalProperties, "owner") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableUserAgentRequest struct { + value *UserAgentRequest + isSet bool +} + +func (v NullableUserAgentRequest) Get() *UserAgentRequest { + return v.value +} + +func (v *NullableUserAgentRequest) Set(val *UserAgentRequest) { + v.value = val + v.isSet = true +} + +func (v NullableUserAgentRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableUserAgentRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserAgentRequest(val *UserAgentRequest) *NullableUserAgentRequest { + return &NullableUserAgentRequest{value: val, isSet: true} +} + +func (v NullableUserAgentRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserAgentRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_agent_response.go b/packages/client-go/model_user_agent_response.go new file mode 100644 index 000000000000..6d496ae63d6c --- /dev/null +++ b/packages/client-go/model_user_agent_response.go @@ -0,0 +1,254 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// checks if the UserAgentResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserAgentResponse{} + +// UserAgentResponse struct for UserAgentResponse +type UserAgentResponse struct { + Username string `json:"username"` + Token string `json:"token"` + UserUid string `json:"user_uid"` + UserPk int32 `json:"user_pk"` + AdditionalProperties map[string]interface{} +} + +type _UserAgentResponse UserAgentResponse + +// NewUserAgentResponse instantiates a new UserAgentResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserAgentResponse(username string, token string, userUid string, userPk int32) *UserAgentResponse { + this := UserAgentResponse{} + this.Username = username + this.Token = token + this.UserUid = userUid + this.UserPk = userPk + return &this +} + +// NewUserAgentResponseWithDefaults instantiates a new UserAgentResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserAgentResponseWithDefaults() *UserAgentResponse { + this := UserAgentResponse{} + return &this +} + +// GetUsername returns the Username field value +func (o *UserAgentResponse) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *UserAgentResponse) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *UserAgentResponse) SetUsername(v string) { + o.Username = v +} + +// GetToken returns the Token field value +func (o *UserAgentResponse) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *UserAgentResponse) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *UserAgentResponse) SetToken(v string) { + o.Token = v +} + +// GetUserUid returns the UserUid field value +func (o *UserAgentResponse) GetUserUid() string { + if o == nil { + var ret string + return ret + } + + return o.UserUid +} + +// GetUserUidOk returns a tuple with the UserUid field value +// and a boolean to check if the value has been set. +func (o *UserAgentResponse) GetUserUidOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.UserUid, true +} + +// SetUserUid sets field value +func (o *UserAgentResponse) SetUserUid(v string) { + o.UserUid = v +} + +// GetUserPk returns the UserPk field value +func (o *UserAgentResponse) GetUserPk() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.UserPk +} + +// GetUserPkOk returns a tuple with the UserPk field value +// and a boolean to check if the value has been set. +func (o *UserAgentResponse) GetUserPkOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.UserPk, true +} + +// SetUserPk sets field value +func (o *UserAgentResponse) SetUserPk(v int32) { + o.UserPk = v +} + +func (o UserAgentResponse) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserAgentResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["username"] = o.Username + toSerialize["token"] = o.Token + toSerialize["user_uid"] = o.UserUid + toSerialize["user_pk"] = o.UserPk + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *UserAgentResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "username", + "token", + "user_uid", + "user_pk", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserAgentResponse := _UserAgentResponse{} + + err = json.Unmarshal(data, &varUserAgentResponse) + + if err != nil { + return err + } + + *o = UserAgentResponse(varUserAgentResponse) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "username") + delete(additionalProperties, "token") + delete(additionalProperties, "user_uid") + delete(additionalProperties, "user_pk") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableUserAgentResponse struct { + value *UserAgentResponse + isSet bool +} + +func (v NullableUserAgentResponse) Get() *UserAgentResponse { + return v.value +} + +func (v *NullableUserAgentResponse) Set(val *UserAgentResponse) { + v.value = val + v.isSet = true +} + +func (v NullableUserAgentResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableUserAgentResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserAgentResponse(val *UserAgentResponse) *NullableUserAgentResponse { + return &NullableUserAgentResponse{value: val, isSet: true} +} + +func (v NullableUserAgentResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserAgentResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_type_enum.go b/packages/client-go/model_user_type_enum.go index c3d0f350b9f9..25ea0ea90f78 100644 --- a/packages/client-go/model_user_type_enum.go +++ b/packages/client-go/model_user_type_enum.go @@ -25,6 +25,7 @@ const ( USERTYPEENUM_EXTERNAL UserTypeEnum = "external" USERTYPEENUM_SERVICE_ACCOUNT UserTypeEnum = "service_account" USERTYPEENUM_INTERNAL_SERVICE_ACCOUNT UserTypeEnum = "internal_service_account" + USERTYPEENUM_AGENT UserTypeEnum = "agent" ) // All allowed values of UserTypeEnum enum @@ -33,6 +34,7 @@ var AllowedUserTypeEnumEnumValues = []UserTypeEnum{ "external", "service_account", "internal_service_account", + "agent", } func (v *UserTypeEnum) UnmarshalJSON(src []byte) error { diff --git a/packages/client-rust/src/apis/core_api.rs b/packages/client-rust/src/apis/core_api.rs index 15884aafb3cb..91f34c23017c 100644 --- a/packages/client-rust/src/apis/core_api.rs +++ b/packages/client-rust/src/apis/core_api.rs @@ -392,6 +392,15 @@ pub enum CoreTokensRetrieveError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`core_tokens_rotate_create`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreTokensRotateCreateError { + Status403(), + Status400(models::ValidationError), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`core_tokens_set_key_create`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -475,6 +484,24 @@ pub enum CoreUserConsentUsedByListError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`core_users_agent_allowed_apps_update`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreUsersAgentAllowedAppsUpdateError { + Status400(), + Status403(), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`core_users_agent_create`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreUsersAgentCreateError { + Status400(models::ValidationError), + Status403(models::GenericError), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`core_users_create`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -3481,6 +3508,70 @@ pub async fn core_tokens_retrieve( } } +/// Rotate the token key and reset the expiry to 24 hours. Only callable by the token owner, the +/// owning agent's human owner, or a superuser. +pub async fn core_tokens_rotate_create( + configuration: &configuration::Configuration, + identifier: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_path_identifier = identifier; + + let uri_str = format!( + "{}/core/tokens/{identifier}/rotate/", + configuration.base_path, + identifier = crate::apis::urlencode(p_path_identifier) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => { + return Err(Error::from(serde_json::Error::custom( + "Received `text/plain` content type response that cannot be converted to \ + `models::TokenView`", + ))); + } + ContentType::Unsupported(unknown_type) => { + return Err(Error::from(serde_json::Error::custom(format!( + "Received `{unknown_type}` content type response that cannot be converted to \ + `models::TokenView`" + )))); + } + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// Set token key. Action is logged as event. `authentik_core.set_token_key` permission is required. pub async fn core_tokens_set_key_create( configuration: &configuration::Configuration, @@ -4024,6 +4115,132 @@ pub async fn core_user_consent_used_by_list( } } +/// Replace the allowed application list for an agent user. Caller must be the agent's owner or a +/// superuser. +pub async fn core_users_agent_allowed_apps_update( + configuration: &configuration::Configuration, + id: i32, + user_agent_allowed_apps_request: models::UserAgentAllowedAppsRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_path_id = id; + let p_body_user_agent_allowed_apps_request = user_agent_allowed_apps_request; + + let uri_str = format!( + "{}/core/users/{id}/agent_allowed_apps/", + configuration.base_path, + id = p_path_id + ); + let mut req_builder = configuration.client.request(reqwest::Method::PUT, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_body_user_agent_allowed_apps_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => { + return Err(Error::from(serde_json::Error::custom( + "Received `text/plain` content type response that cannot be converted to \ + `models::UserAgentAllowedApps`", + ))); + } + ContentType::Unsupported(unknown_type) => { + return Err(Error::from(serde_json::Error::custom(format!( + "Received `{unknown_type}` content type response that cannot be converted to \ + `models::UserAgentAllowedApps`" + )))); + } + } + } else { + let content = resp.text().await?; + let entity: Option = + serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +/// Create a new agent user. Enterprise only. Caller must be an internal user. +pub async fn core_users_agent_create( + configuration: &configuration::Configuration, + user_agent_request: models::UserAgentRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_body_user_agent_request = user_agent_request; + + let uri_str = format!("{}/core/users/agent/", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_body_user_agent_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => { + return Err(Error::from(serde_json::Error::custom( + "Received `text/plain` content type response that cannot be converted to \ + `models::UserAgentResponse`", + ))); + } + ContentType::Unsupported(unknown_type) => { + return Err(Error::from(serde_json::Error::custom(format!( + "Received `{unknown_type}` content type response that cannot be converted to \ + `models::UserAgentResponse`" + )))); + } + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// User Viewset pub async fn core_users_create( configuration: &configuration::Configuration, diff --git a/packages/client-rust/src/models/mod.rs b/packages/client-rust/src/models/mod.rs index f91673481755..0955672b1cbb 100644 --- a/packages/client-rust/src/models/mod.rs +++ b/packages/client-rust/src/models/mod.rs @@ -1634,6 +1634,14 @@ pub mod user_account_request; pub use self::user_account_request::UserAccountRequest; pub mod user_account_serializer_for_role_request; pub use self::user_account_serializer_for_role_request::UserAccountSerializerForRoleRequest; +pub mod user_agent_allowed_apps; +pub use self::user_agent_allowed_apps::UserAgentAllowedApps; +pub mod user_agent_allowed_apps_request; +pub use self::user_agent_allowed_apps_request::UserAgentAllowedAppsRequest; +pub mod user_agent_request; +pub use self::user_agent_request::UserAgentRequest; +pub mod user_agent_response; +pub use self::user_agent_response::UserAgentResponse; pub mod user_attribute_enum; pub use self::user_attribute_enum::UserAttributeEnum; pub mod user_consent; diff --git a/packages/client-rust/src/models/user_agent_allowed_apps.rs b/packages/client-rust/src/models/user_agent_allowed_apps.rs new file mode 100644 index 000000000000..1984504b1bac --- /dev/null +++ b/packages/client-rust/src/models/user_agent_allowed_apps.rs @@ -0,0 +1,25 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// UserAgentAllowedApps : Payload to update an agent's allowed applications +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserAgentAllowedApps { + #[serde(rename = "allowed_apps")] + pub allowed_apps: Vec, +} + +impl UserAgentAllowedApps { + /// Payload to update an agent's allowed applications + pub fn new(allowed_apps: Vec) -> UserAgentAllowedApps { + UserAgentAllowedApps { allowed_apps } + } +} diff --git a/packages/client-rust/src/models/user_agent_allowed_apps_request.rs b/packages/client-rust/src/models/user_agent_allowed_apps_request.rs new file mode 100644 index 000000000000..c9397c43cec4 --- /dev/null +++ b/packages/client-rust/src/models/user_agent_allowed_apps_request.rs @@ -0,0 +1,25 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// UserAgentAllowedAppsRequest : Payload to update an agent's allowed applications +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserAgentAllowedAppsRequest { + #[serde(rename = "allowed_apps")] + pub allowed_apps: Vec, +} + +impl UserAgentAllowedAppsRequest { + /// Payload to update an agent's allowed applications + pub fn new(allowed_apps: Vec) -> UserAgentAllowedAppsRequest { + UserAgentAllowedAppsRequest { allowed_apps } + } +} diff --git a/packages/client-rust/src/models/user_agent_request.rs b/packages/client-rust/src/models/user_agent_request.rs new file mode 100644 index 000000000000..de3bb02e8990 --- /dev/null +++ b/packages/client-rust/src/models/user_agent_request.rs @@ -0,0 +1,27 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// UserAgentRequest : Payload to create an agent user +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserAgentRequest { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "owner", skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + +impl UserAgentRequest { + /// Payload to create an agent user + pub fn new(name: String) -> UserAgentRequest { + UserAgentRequest { name, owner: None } + } +} diff --git a/packages/client-rust/src/models/user_agent_response.rs b/packages/client-rust/src/models/user_agent_response.rs new file mode 100644 index 000000000000..252e5901c2d4 --- /dev/null +++ b/packages/client-rust/src/models/user_agent_response.rs @@ -0,0 +1,39 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserAgentResponse { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "token")] + pub token: String, + #[serde(rename = "user_uid")] + pub user_uid: String, + #[serde(rename = "user_pk")] + pub user_pk: i32, +} + +impl UserAgentResponse { + pub fn new( + username: String, + token: String, + user_uid: String, + user_pk: i32, + ) -> UserAgentResponse { + UserAgentResponse { + username, + token, + user_uid, + user_pk, + } + } +} diff --git a/packages/client-rust/src/models/user_type_enum.rs b/packages/client-rust/src/models/user_type_enum.rs index 011d8cbea5a0..63a5c3670e4e 100644 --- a/packages/client-rust/src/models/user_type_enum.rs +++ b/packages/client-rust/src/models/user_type_enum.rs @@ -21,6 +21,8 @@ pub enum UserTypeEnum { ServiceAccount, #[serde(rename = "internal_service_account")] InternalServiceAccount, + #[serde(rename = "agent")] + Agent, } impl std::fmt::Display for UserTypeEnum { @@ -30,6 +32,7 @@ impl std::fmt::Display for UserTypeEnum { Self::External => write!(f, "external"), Self::ServiceAccount => write!(f, "service_account"), Self::InternalServiceAccount => write!(f, "internal_service_account"), + Self::Agent => write!(f, "agent"), } } } diff --git a/packages/client-ts/src/apis/CoreApi.ts b/packages/client-ts/src/apis/CoreApi.ts index 4c05001ec7ec..02a011be1f29 100644 --- a/packages/client-ts/src/apis/CoreApi.ts +++ b/packages/client-ts/src/apis/CoreApi.ts @@ -53,6 +53,10 @@ import type { UsedBy, User, UserAccountRequest, + UserAgentAllowedApps, + UserAgentAllowedAppsRequest, + UserAgentRequest, + UserAgentResponse, UserConsent, UserPasswordSetRequest, UserPath, @@ -102,6 +106,10 @@ import { TransactionApplicationResponseFromJSON, UsedByFromJSON, UserAccountRequestToJSON, + UserAgentAllowedAppsFromJSON, + UserAgentAllowedAppsRequestToJSON, + UserAgentRequestToJSON, + UserAgentResponseFromJSON, UserConsentFromJSON, UserFromJSON, UserPasswordSetRequestToJSON, @@ -358,6 +366,10 @@ export interface CoreTokensRetrieveRequest { identifier: string; } +export interface CoreTokensRotateCreateRequest { + identifier: string; +} + export interface CoreTokensSetKeyCreateRequest { identifier: string; tokenSetKeyRequest: TokenSetKeyRequest; @@ -401,6 +413,15 @@ export interface CoreUserConsentUsedByListRequest { id: number; } +export interface CoreUsersAgentAllowedAppsUpdateRequest { + id: number; + userAgentAllowedAppsRequest: UserAgentAllowedAppsRequest; +} + +export interface CoreUsersAgentCreateRequest { + userAgentRequest: UserAgentRequest; +} + export interface CoreUsersCreateRequest { userRequest: UserRequest; } @@ -3575,6 +3596,70 @@ export class CoreApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for coreTokensRotateCreate without sending the request + */ + async coreTokensRotateCreateRequestOpts( + requestParameters: CoreTokensRotateCreateRequest, + ): Promise { + if (requestParameters["identifier"] == null) { + throw new runtime.RequiredError( + "identifier", + 'Required parameter "identifier" was null or undefined when calling coreTokensRotateCreate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/tokens/{identifier}/rotate/`; + urlPath = urlPath.replace( + `{${"identifier"}}`, + encodeURIComponent(String(requestParameters["identifier"])), + ); + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * Rotate the token key and reset the expiry to 24 hours. Only callable by the token owner, the owning agent\'s human owner, or a superuser. + */ + async coreTokensRotateCreateRaw( + requestParameters: CoreTokensRotateCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.coreTokensRotateCreateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => TokenViewFromJSON(jsonValue)); + } + + /** + * Rotate the token key and reset the expiry to 24 hours. Only callable by the token owner, the owning agent\'s human owner, or a superuser. + */ + async coreTokensRotateCreate( + requestParameters: CoreTokensRotateCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.coreTokensRotateCreateRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for coreTokensSetKeyCreate without sending the request */ @@ -4182,6 +4267,150 @@ export class CoreApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for coreUsersAgentAllowedAppsUpdate without sending the request + */ + async coreUsersAgentAllowedAppsUpdateRequestOpts( + requestParameters: CoreUsersAgentAllowedAppsUpdateRequest, + ): Promise { + if (requestParameters["id"] == null) { + throw new runtime.RequiredError( + "id", + 'Required parameter "id" was null or undefined when calling coreUsersAgentAllowedAppsUpdate().', + ); + } + + if (requestParameters["userAgentAllowedAppsRequest"] == null) { + throw new runtime.RequiredError( + "userAgentAllowedAppsRequest", + 'Required parameter "userAgentAllowedAppsRequest" was null or undefined when calling coreUsersAgentAllowedAppsUpdate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/users/{id}/agent_allowed_apps/`; + urlPath = urlPath.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters["id"]))); + + return { + path: urlPath, + method: "PUT", + headers: headerParameters, + query: queryParameters, + body: UserAgentAllowedAppsRequestToJSON( + requestParameters["userAgentAllowedAppsRequest"], + ), + }; + } + + /** + * Replace the allowed application list for an agent user. Caller must be the agent\'s owner or a superuser. + */ + async coreUsersAgentAllowedAppsUpdateRaw( + requestParameters: CoreUsersAgentAllowedAppsUpdateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = + await this.coreUsersAgentAllowedAppsUpdateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + UserAgentAllowedAppsFromJSON(jsonValue), + ); + } + + /** + * Replace the allowed application list for an agent user. Caller must be the agent\'s owner or a superuser. + */ + async coreUsersAgentAllowedAppsUpdate( + requestParameters: CoreUsersAgentAllowedAppsUpdateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.coreUsersAgentAllowedAppsUpdateRaw( + requestParameters, + initOverrides, + ); + return await response.value(); + } + + /** + * Creates request options for coreUsersAgentCreate without sending the request + */ + async coreUsersAgentCreateRequestOpts( + requestParameters: CoreUsersAgentCreateRequest, + ): Promise { + if (requestParameters["userAgentRequest"] == null) { + throw new runtime.RequiredError( + "userAgentRequest", + 'Required parameter "userAgentRequest" was null or undefined when calling coreUsersAgentCreate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/users/agent/`; + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: UserAgentRequestToJSON(requestParameters["userAgentRequest"]), + }; + } + + /** + * Create a new agent user. Enterprise only. Caller must be an internal user. + */ + async coreUsersAgentCreateRaw( + requestParameters: CoreUsersAgentCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.coreUsersAgentCreateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + UserAgentResponseFromJSON(jsonValue), + ); + } + + /** + * Create a new agent user. Enterprise only. Caller must be an internal user. + */ + async coreUsersAgentCreate( + requestParameters: CoreUsersAgentCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.coreUsersAgentCreateRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for coreUsersCreate without sending the request */ diff --git a/packages/client-ts/src/models/UserAgentAllowedApps.ts b/packages/client-ts/src/models/UserAgentAllowedApps.ts new file mode 100644 index 000000000000..2da5d3c12651 --- /dev/null +++ b/packages/client-ts/src/models/UserAgentAllowedApps.ts @@ -0,0 +1,68 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * Payload to update an agent's allowed applications + * @export + * @interface UserAgentAllowedApps + */ +export interface UserAgentAllowedApps { + /** + * + * @type {Array} + * @memberof UserAgentAllowedApps + */ + allowedApps: Array; +} + +/** + * Check if a given object implements the UserAgentAllowedApps interface. + */ +export function instanceOfUserAgentAllowedApps(value: object): value is UserAgentAllowedApps { + if (!("allowedApps" in value) || value["allowedApps"] === undefined) return false; + return true; +} + +export function UserAgentAllowedAppsFromJSON(json: any): UserAgentAllowedApps { + return UserAgentAllowedAppsFromJSONTyped(json, false); +} + +export function UserAgentAllowedAppsFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserAgentAllowedApps { + if (json == null) { + return json; + } + return { + allowedApps: json["allowed_apps"], + }; +} + +export function UserAgentAllowedAppsToJSON(json: any): UserAgentAllowedApps { + return UserAgentAllowedAppsToJSONTyped(json, false); +} + +export function UserAgentAllowedAppsToJSONTyped( + value?: UserAgentAllowedApps | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + allowed_apps: value["allowedApps"], + }; +} diff --git a/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts b/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts new file mode 100644 index 000000000000..4d4588b5cfd9 --- /dev/null +++ b/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts @@ -0,0 +1,70 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * Payload to update an agent's allowed applications + * @export + * @interface UserAgentAllowedAppsRequest + */ +export interface UserAgentAllowedAppsRequest { + /** + * + * @type {Array} + * @memberof UserAgentAllowedAppsRequest + */ + allowedApps: Array; +} + +/** + * Check if a given object implements the UserAgentAllowedAppsRequest interface. + */ +export function instanceOfUserAgentAllowedAppsRequest( + value: object, +): value is UserAgentAllowedAppsRequest { + if (!("allowedApps" in value) || value["allowedApps"] === undefined) return false; + return true; +} + +export function UserAgentAllowedAppsRequestFromJSON(json: any): UserAgentAllowedAppsRequest { + return UserAgentAllowedAppsRequestFromJSONTyped(json, false); +} + +export function UserAgentAllowedAppsRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserAgentAllowedAppsRequest { + if (json == null) { + return json; + } + return { + allowedApps: json["allowed_apps"], + }; +} + +export function UserAgentAllowedAppsRequestToJSON(json: any): UserAgentAllowedAppsRequest { + return UserAgentAllowedAppsRequestToJSONTyped(json, false); +} + +export function UserAgentAllowedAppsRequestToJSONTyped( + value?: UserAgentAllowedAppsRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + allowed_apps: value["allowedApps"], + }; +} diff --git a/packages/client-ts/src/models/UserAgentRequest.ts b/packages/client-ts/src/models/UserAgentRequest.ts new file mode 100644 index 000000000000..205f9424556f --- /dev/null +++ b/packages/client-ts/src/models/UserAgentRequest.ts @@ -0,0 +1,76 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * Payload to create an agent user + * @export + * @interface UserAgentRequest + */ +export interface UserAgentRequest { + /** + * + * @type {string} + * @memberof UserAgentRequest + */ + name: string; + /** + * + * @type {number} + * @memberof UserAgentRequest + */ + owner?: number; +} + +/** + * Check if a given object implements the UserAgentRequest interface. + */ +export function instanceOfUserAgentRequest(value: object): value is UserAgentRequest { + if (!("name" in value) || value["name"] === undefined) return false; + return true; +} + +export function UserAgentRequestFromJSON(json: any): UserAgentRequest { + return UserAgentRequestFromJSONTyped(json, false); +} + +export function UserAgentRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserAgentRequest { + if (json == null) { + return json; + } + return { + name: json["name"], + owner: json["owner"] == null ? undefined : json["owner"], + }; +} + +export function UserAgentRequestToJSON(json: any): UserAgentRequest { + return UserAgentRequestToJSONTyped(json, false); +} + +export function UserAgentRequestToJSONTyped( + value?: UserAgentRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + name: value["name"], + owner: value["owner"], + }; +} diff --git a/packages/client-ts/src/models/UserAgentResponse.ts b/packages/client-ts/src/models/UserAgentResponse.ts new file mode 100644 index 000000000000..f853256a7cfc --- /dev/null +++ b/packages/client-ts/src/models/UserAgentResponse.ts @@ -0,0 +1,95 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface UserAgentResponse + */ +export interface UserAgentResponse { + /** + * + * @type {string} + * @memberof UserAgentResponse + */ + username: string; + /** + * + * @type {string} + * @memberof UserAgentResponse + */ + token: string; + /** + * + * @type {string} + * @memberof UserAgentResponse + */ + userUid: string; + /** + * + * @type {number} + * @memberof UserAgentResponse + */ + userPk: number; +} + +/** + * Check if a given object implements the UserAgentResponse interface. + */ +export function instanceOfUserAgentResponse(value: object): value is UserAgentResponse { + if (!("username" in value) || value["username"] === undefined) return false; + if (!("token" in value) || value["token"] === undefined) return false; + if (!("userUid" in value) || value["userUid"] === undefined) return false; + if (!("userPk" in value) || value["userPk"] === undefined) return false; + return true; +} + +export function UserAgentResponseFromJSON(json: any): UserAgentResponse { + return UserAgentResponseFromJSONTyped(json, false); +} + +export function UserAgentResponseFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserAgentResponse { + if (json == null) { + return json; + } + return { + username: json["username"], + token: json["token"], + userUid: json["user_uid"], + userPk: json["user_pk"], + }; +} + +export function UserAgentResponseToJSON(json: any): UserAgentResponse { + return UserAgentResponseToJSONTyped(json, false); +} + +export function UserAgentResponseToJSONTyped( + value?: UserAgentResponse | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + username: value["username"], + token: value["token"], + user_uid: value["userUid"], + user_pk: value["userPk"], + }; +} diff --git a/packages/client-ts/src/models/UserTypeEnum.ts b/packages/client-ts/src/models/UserTypeEnum.ts index c79081342a94..f6b272906bbf 100644 --- a/packages/client-ts/src/models/UserTypeEnum.ts +++ b/packages/client-ts/src/models/UserTypeEnum.ts @@ -21,6 +21,7 @@ export const UserTypeEnum = { External: "external", ServiceAccount: "service_account", InternalServiceAccount: "internal_service_account", + Agent: "agent", UnknownDefaultOpenApi: "11184809", } as const; export type UserTypeEnum = (typeof UserTypeEnum)[keyof typeof UserTypeEnum]; diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index e24455ffaf20..655914a4ebb6 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -818,6 +818,10 @@ export * from "./UsedByActionEnum"; export * from "./User"; export * from "./UserAccountRequest"; export * from "./UserAccountSerializerForRoleRequest"; +export * from "./UserAgentAllowedApps"; +export * from "./UserAgentAllowedAppsRequest"; +export * from "./UserAgentRequest"; +export * from "./UserAgentResponse"; export * from "./UserAttributeEnum"; export * from "./UserConsent"; export * from "./UserCreationModeEnum"; diff --git a/schema.yml b/schema.yml index 49ef628cf12d..24e2123aca97 100644 --- a/schema.yml +++ b/schema.yml @@ -3896,6 +3896,33 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/tokens/{identifier}/rotate/: + post: + operationId: core_tokens_rotate_create + description: |- + Rotate the token key and reset the expiry to 24 hours. Only callable by the token + owner, the owning agent's human owner, or a superuser. + parameters: + - in: path + name: identifier + schema: + type: string + required: true + tags: + - core + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenView' + description: '' + '403': + description: Not the token owner, agent owner, or superuser + '400': + $ref: '#/components/responses/ValidationErrorResponse' /core/tokens/{identifier}/set_key/: post: operationId: core_tokens_set_key_create @@ -4406,6 +4433,40 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/users/{id}/agent_allowed_apps/: + put: + operationId: core_users_agent_allowed_apps_update + description: |- + Replace the allowed application list for an agent user. + Caller must be the agent's owner or a superuser. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAgentAllowedAppsRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserAgentAllowedApps' + description: '' + '400': + description: Invalid app UUIDs or owner lacks access + '403': + description: Not the agent's owner or superuser /core/users/{id}/impersonate/: post: operationId: core_users_impersonate_create @@ -4550,6 +4611,32 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/users/agent/: + post: + operationId: core_users_agent_create + description: Create a new agent user. Enterprise only. Caller must be an internal + user. + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAgentRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserAgentResponse' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /core/users/export/: post: operationId: core_users_export_create @@ -56801,6 +56888,56 @@ components: type: integer required: - pk + UserAgentAllowedApps: + type: object + description: Payload to update an agent's allowed applications + properties: + allowed_apps: + type: array + items: + type: string + format: uuid + required: + - allowed_apps + UserAgentAllowedAppsRequest: + type: object + description: Payload to update an agent's allowed applications + properties: + allowed_apps: + type: array + items: + type: string + format: uuid + required: + - allowed_apps + UserAgentRequest: + type: object + description: Payload to create an agent user + properties: + name: + type: string + minLength: 1 + maxLength: 150 + owner: + type: integer + required: + - name + UserAgentResponse: + type: object + properties: + username: + type: string + token: + type: string + user_uid: + type: string + user_pk: + type: integer + required: + - token + - user_pk + - user_uid + - username UserAttributeEnum: enum: - username @@ -57706,6 +57843,7 @@ components: - external - service_account - internal_service_account + - agent type: string UserVerificationEnum: enum: diff --git a/web/src/admin/users/AgentForm.ts b/web/src/admin/users/AgentForm.ts new file mode 100644 index 000000000000..4c5f8e5a14fa --- /dev/null +++ b/web/src/admin/users/AgentForm.ts @@ -0,0 +1,107 @@ +import "#components/ak-hidden-text-input"; +import "#elements/forms/HorizontalFormElement"; +import "#components/ak-text-input"; + +import { DEFAULT_CONFIG } from "#common/api/config"; + +import { Form } from "#elements/forms/Form"; +import { ModalForm } from "#elements/forms/ModalForm"; +import { SlottedTemplateResult } from "#elements/types"; + +import { CoreApi, UserAgentRequest, UserAgentResponse } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +@customElement("ak-user-agent-form") +export class AgentForm extends Form { + public static override verboseName = msg("Agent"); + public static override verboseNamePlural = msg("Agents"); + public override cancelButtonLabel = msg("Close"); + + @property({ attribute: false }) + result: UserAgentResponse | null = null; + + getSuccessMessage(): string { + return msg("Successfully created agent user."); + } + + async send(data: UserAgentRequest): Promise { + const result = await new CoreApi(DEFAULT_CONFIG).coreUsersAgentCreate({ + userAgentRequest: data, + }); + this.result = result; + if (this.parentElement instanceof ModalForm) { + this.parentElement.showSubmitButton = false; + } + return result; + } + + public override reset(): void { + super.reset(); + this.result = null; + if (this.parentElement instanceof ModalForm) { + this.parentElement.showSubmitButton = true; + } + } + + protected override renderForm(): TemplateResult { + return html``; + } + + protected renderResponseForm(): SlottedTemplateResult { + return html`

+ ${msg( + "Use the username and token below to authenticate. The token expires in 24 hours and must be rotated before expiry.", + )} +

+
+ + + + +
`; + } + + protected override renderFormWrapper(): SlottedTemplateResult { + if (this.result) { + return this.renderResponseForm(); + } + return super.renderFormWrapper(); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-user-agent-form": AgentForm; + } +} diff --git a/web/src/admin/users/UserForm.ts b/web/src/admin/users/UserForm.ts index 5b696c9dfaea..c93019bb82fd 100644 --- a/web/src/admin/users/UserForm.ts +++ b/web/src/admin/users/UserForm.ts @@ -102,6 +102,10 @@ export class UserForm extends ModelForm { verboseName: msg("Service Account"), verboseNamePlural: msg("Service Accounts"), })) + .with(UserTypeEnum.Agent, () => ({ + verboseName: msg("Agent User"), + verboseNamePlural: msg("Agent Users"), + })) .otherwise(() => ({ verboseName: msg("User"), verboseNamePlural: msg("Users"), @@ -203,27 +207,44 @@ export class UserForm extends ModelForm { ${this.userType ? null - : html`[]} - >`} + : this.instance?.type === UserTypeEnum.Agent + ? html`[]} + >` + : html`[]} + >`} { } } +@customElement("ak-user-agent-result-page") +export class AgentResultPage extends WizardPage { + public static styles: CSSResult[] = [PFForm, PFFormControl]; + + public override headline = msg("Review Credentials"); + + @state() + protected result: UserAgentResponse | null = null; + + public override activeCallback = async (): Promise => { + const result = this.host.state[AGENT_FORM_SLOT]; + + if (!result) { + throw new TypeError("Expected agent creation result in wizard state."); + } + + this.result = result; + + this.host.valid = true; + this.host.cancelable = false; + }; + + public override nextCallback = async (): Promise => true; + + protected override render(): SlottedTemplateResult { + if (!this.result) { + return null; + } + + const { username, token } = this.result; + + return html`

${msg("Review Credentials")}

+

+ ${msg( + "Use the username and token below to authenticate. The token expires in 24 hours and must be rotated before expiry.", + )} +

+
+ + +
`; + } +} + @customElement("ak-user-wizard") export class AKUserWizard extends CreateWizard { /** @@ -128,20 +203,28 @@ export class AKUserWizard extends CreateWizard { protected override selectSteps(type: TypeCreate, currentSteps: string[]): string[] { const { modelName } = type; - const serviceAccount = modelName === UserTypeEnum.ServiceAccount; - if (!serviceAccount) { - return super.selectSteps(type, currentSteps); + if (modelName === UserTypeEnum.Agent) { + return [AGENT_FORM_SLOT, AGENT_RESULT_SLOT]; } - return [ - // --- - SERVICE_ACCOUNT_FORM_SLOT, - SERVICE_ACCOUNT_RESULT_SLOT, - ]; + if (modelName === UserTypeEnum.ServiceAccount) { + return [SERVICE_ACCOUNT_FORM_SLOT, SERVICE_ACCOUNT_RESULT_SLOT]; + } + + return super.selectSteps(type, currentSteps); } protected override renderWizardStep(type: TypeCreate): SlottedTemplateResult { + if (type.modelName === UserTypeEnum.Agent) { + return [ + super.renderWizardStep(type), + html``, + ]; + } + if (type.modelName === UserTypeEnum.ServiceAccount) { return [ super.renderWizardStep(type), @@ -155,7 +238,10 @@ export class AKUserWizard extends CreateWizard { } protected override assembleFormProps(type: TypeCreate): LitPropertyRecord { - if (type.modelName === UserTypeEnum.ServiceAccount) { + if ( + type.modelName === UserTypeEnum.Agent || + type.modelName === UserTypeEnum.ServiceAccount + ) { return {}; } @@ -171,6 +257,7 @@ export class AKUserWizard extends CreateWizard { declare global { interface HTMLElementTagNameMap { "ak-user-wizard": AKUserWizard; + "ak-user-agent-result-page": AgentResultPage; "ak-user-service-account-result-page": ServiceAccountResultPage; } } diff --git a/web/src/common/labels.ts b/web/src/common/labels.ts index be5b8d917abf..58e93480bb30 100644 --- a/web/src/common/labels.ts +++ b/web/src/common/labels.ts @@ -118,6 +118,7 @@ const _userTypeToLabel = new Map([ [UserTypeEnum.External, msg("External")], [UserTypeEnum.ServiceAccount, msg("Service account")], [UserTypeEnum.InternalServiceAccount, msg("Service account (internal)")], + [UserTypeEnum.Agent, msg("Agent")], ]); export const userTypeToLabel = (type?: UserTypeEnum): string => From 66716b829631bd43b8aba58cbdbf67129ae8a24d Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Sun, 12 Apr 2026 23:01:25 -0400 Subject: [PATCH 02/23] exchange token for session --- authentik/core/api/tokens.py | 49 +++++++++++ authentik/core/tests/test_token_api.py | 70 +++++++++++++++ authentik/policies/engine.py | 42 +++++++++ authentik/policies/tests/test_engine.py | 89 ++++++++++++++++++- packages/client-go/api_core.go | 100 ++++++++++++++++++++++ packages/client-rust/src/apis/core_api.rs | 49 +++++++++++ packages/client-ts/src/apis/CoreApi.ts | 66 ++++++++++++++ schema.yml | 23 +++++ 8 files changed, 487 insertions(+), 1 deletion(-) diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index aaccd5639b61..70535df1c0e0 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any +from django.contrib.auth import login from django.utils.timezone import now from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.decorators import action @@ -204,6 +205,54 @@ def rotate(self, request: Request, identifier: str) -> Response: Event.new(EventAction.SECRET_ROTATE, secret=token).from_http(request) # noqa # nosec return Response(TokenViewSerializer({"key": token.key}).data) + @extend_schema( + request=TokenSetKeySerializer, + responses={ + 204: OpenApiResponse(description="Session created, session cookie set"), + 400: OpenApiResponse(description="Invalid token or not an agent user"), + 403: OpenApiResponse(description="Token expired or agent inactive"), + }, + ) + @action(detail=False, pagination_class=None, filter_backends=[], methods=["POST"]) + @validate(TokenSetKeySerializer) + def session(self, request: Request, body: TokenSetKeySerializer) -> Response: + """Exchange an agent's API token for an authenticated session. Only valid for + active agent users with non-expired INTENT_API tokens.""" + from authentik.core.models import AuthenticatedSession + from authentik.stages.password import BACKEND_INBUILT + + key = body.validated_data.get("key") + token = ( + Token.objects.filter(key=key, intent=TokenIntents.INTENT_API) + .select_related("user") + .first() + ) + if not token: + return Response( + data={"non_field_errors": ["Invalid token."]}, + status=400, + ) + if token.is_expired: + return Response( + data={"non_field_errors": ["Token has expired."]}, + status=403, + ) + if token.user.type != UserTypes.AGENT: + return Response( + data={"non_field_errors": ["Token does not belong to an agent user."]}, + status=400, + ) + if not token.user.is_active: + return Response( + data={"non_field_errors": ["Agent user is inactive."]}, + status=403, + ) + login(request._request, token.user, backend=BACKEND_INBUILT) + session = AuthenticatedSession.from_request(request._request, token.user) + if session: + session.save() + return Response(status=204) + @permission_required("authentik_core.set_token_key") @extend_schema( request=TokenSetKeySerializer(), diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py index 75a579cf8a97..ca7929dc1783 100644 --- a/authentik/core/tests/test_token_api.py +++ b/authentik/core/tests/test_token_api.py @@ -9,10 +9,14 @@ from authentik.core.api.tokens import TokenSerializer from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, + USER_PATH_AGENT, Token, TokenIntents, + User, + UserTypes, ) from authentik.core.tests.utils import create_test_admin_user, create_test_user from authentik.lib.generators import generate_id @@ -255,3 +259,69 @@ def test_serializer_no_request(self): } ).is_valid(raise_exception=True) ) + + +class TestTokenSession(APITestCase): + """Test token-to-session exchange""" + + def _create_agent_with_token(self): + owner = create_test_user() + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)}, + path=USER_PATH_AGENT, + ) + agent.set_unusable_password() + agent.save() + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=agent, + expiring=True, + ) + return owner, agent, token + + def test_session_exchange_success(self): + """Valid agent token creates a session""" + _owner, _agent, token = self._create_agent_with_token() + response = self.client.post( + reverse("authentik_api:token-session"), + data={"key": token.key}, + ) + self.assertEqual(response.status_code, 204) + self.assertIn("authentik_session", response.cookies) + + def test_session_exchange_invalid_token(self): + """Invalid token key is rejected""" + response = self.client.post( + reverse("authentik_api:token-session"), + data={"key": "nonexistent-key"}, + ) + self.assertEqual(response.status_code, 400) + + def test_session_exchange_non_agent(self): + """Token belonging to a non-agent user is rejected""" + user = create_test_user() + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=user, + expiring=True, + ) + response = self.client.post( + reverse("authentik_api:token-session"), + data={"key": token.key}, + ) + self.assertEqual(response.status_code, 400) + + def test_session_exchange_inactive_agent(self): + """Inactive agent is rejected""" + _owner, agent, token = self._create_agent_with_token() + agent.is_active = False + agent.save(update_fields=["is_active"]) + response = self.client.post( + reverse("authentik_api:token-session"), + data={"key": token.key}, + ) + self.assertEqual(response.status_code, 403) diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py index 06bcc5e86a9c..96128a109f86 100644 --- a/authentik/policies/engine.py +++ b/authentik/policies/engine.py @@ -202,9 +202,51 @@ def build(self) -> PolicyEngine: ).observe(proc_info.result._exec_time) return self + def _check_agent_access(self) -> PolicyResult | None: + """For agent users accessing an Application, enforce allowed_apps + owner access. + Returns a deny PolicyResult if the agent should be blocked, or None to continue + with normal policy evaluation.""" + from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, + Application, + UserTypes, + ) + + user = self.request.user + if user.type != UserTypes.AGENT: + return None + if not isinstance(self.__pbm, Application): + return None + + allowed_apps = user.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS, []) + if str(self.__pbm.pk) not in allowed_apps: + return PolicyResult(False, "Agent does not have access to this application.") + + owner_pk = user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + if not owner_pk: + return PolicyResult(False, "Agent has no owner configured.") + + owner = User.objects.filter(pk=owner_pk).first() + if not owner: + return PolicyResult(False, "Agent owner does not exist.") + + from authentik.core.apps import AppAccessWithoutBindings + + owner_engine = PolicyEngine(self.__pbm, owner) + owner_engine.empty_result = AppAccessWithoutBindings.get() + owner_engine.use_cache = False + owner_engine.build() + if not owner_engine.passing: + return PolicyResult(False, "Agent owner does not have access to this application.") + return None + @property def result(self) -> PolicyResult: """Get policy-checking result""" + agent_result = self._check_agent_access() + if agent_result is not None: + return agent_result self.__processes.sort(key=lambda x: x.binding.order) process_results: list[PolicyResult] = [x.result for x in self.__processes if x.result] all_results = list(process_results + self.__cached_policies) diff --git a/authentik/policies/tests/test_engine.py b/authentik/policies/tests/test_engine.py index a2ced2c2ae38..9a32d188eec6 100644 --- a/authentik/policies/tests/test_engine.py +++ b/authentik/policies/tests/test_engine.py @@ -5,7 +5,15 @@ from django.test import TestCase from django.test.utils import CaptureQueriesContext -from authentik.core.models import Group +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_ALLOWED_APPS, + USER_ATTRIBUTE_AGENT_OWNER_PK, + USER_PATH_AGENT, + Application, + Group, + User, + UserTypes, +) from authentik.core.tests.utils import create_test_user from authentik.lib.generators import generate_id from authentik.policies.dummy.models import DummyPolicy @@ -209,3 +217,82 @@ def test_engine_group_complex(self): engine.build() self.assertLess(ctx.final_queries, 1000) self.assertTrue(engine.result.passing) + + +class TestPolicyEngineAgent(TestCase): + """PolicyEngine agent access enforcement tests""" + + def setUp(self): + clear_policy_cache() + self.owner = create_test_user() + self.app = Application.objects.create(name=generate_id(), slug=generate_id()) + + def _create_agent(self, allowed_apps=None): + return User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: str(self.owner.pk), + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: allowed_apps if allowed_apps is not None else [], + }, + path=USER_PATH_AGENT, + ) + + def test_agent_allowed_app_passes(self): + """Agent with app in allowed_apps and owner access passes""" + agent = self._create_agent(allowed_apps=[str(self.app.pk)]) + engine = PolicyEngine(self.app, agent) + engine.use_cache = False + engine.build() + self.assertTrue(engine.passing) + + def test_agent_disallowed_app_denied(self): + """Agent without app in allowed_apps is denied""" + agent = self._create_agent(allowed_apps=[]) + engine = PolicyEngine(self.app, agent) + engine.use_cache = False + engine.build() + self.assertFalse(engine.passing) + + def test_agent_empty_allowed_apps_denied(self): + """Agent with empty allowed_apps is denied even for unbound apps""" + agent = self._create_agent() + engine = PolicyEngine(self.app, agent) + engine.empty_result = True + engine.use_cache = False + engine.build() + self.assertFalse(engine.passing) + + def test_non_agent_unaffected(self): + """Non-agent users are not affected by agent access check""" + engine = PolicyEngine(self.app, self.owner) + engine.empty_result = True + engine.use_cache = False + engine.build() + self.assertTrue(engine.passing) + + def test_agent_missing_owner_denied(self): + """Agent with non-existent owner is denied""" + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={ + USER_ATTRIBUTE_AGENT_OWNER_PK: "999999", + USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [str(self.app.pk)], + }, + path=USER_PATH_AGENT, + ) + engine = PolicyEngine(self.app, agent) + engine.use_cache = False + engine.build() + self.assertFalse(engine.passing) + + def test_agent_non_application_target_unaffected(self): + """Agent check only applies to Application targets""" + agent = self._create_agent(allowed_apps=[]) + pbm = PolicyBindingModel.objects.create() + engine = PolicyEngine(pbm, agent) + engine.empty_result = True + engine.use_cache = False + engine.build() + self.assertTrue(engine.passing) diff --git a/packages/client-go/api_core.go b/packages/client-go/api_core.go index 5036e4ee65de..35a2ee637ad7 100644 --- a/packages/client-go/api_core.go +++ b/packages/client-go/api_core.go @@ -6181,6 +6181,106 @@ func (a *CoreAPIService) CoreTokensRotateCreateExecute(r ApiCoreTokensRotateCrea return localVarReturnValue, localVarHTTPResponse, nil } +type ApiCoreTokensSessionCreateRequest struct { + ctx context.Context + ApiService *CoreAPIService + tokenSetKeyRequest *TokenSetKeyRequest +} + +func (r ApiCoreTokensSessionCreateRequest) TokenSetKeyRequest(tokenSetKeyRequest TokenSetKeyRequest) ApiCoreTokensSessionCreateRequest { + r.tokenSetKeyRequest = &tokenSetKeyRequest + return r +} + +func (r ApiCoreTokensSessionCreateRequest) Execute() (*http.Response, error) { + return r.ApiService.CoreTokensSessionCreateExecute(r) +} + +/* +CoreTokensSessionCreate Method for CoreTokensSessionCreate + +Exchange an agent's API token for an authenticated session. Only valid for +active agent users with non-expired INTENT_API tokens. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiCoreTokensSessionCreateRequest +*/ +func (a *CoreAPIService) CoreTokensSessionCreate(ctx context.Context) ApiCoreTokensSessionCreateRequest { + return ApiCoreTokensSessionCreateRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *CoreAPIService) CoreTokensSessionCreateExecute(r ApiCoreTokensSessionCreateRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreTokensSessionCreate") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/tokens/session/" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.tokenSetKeyRequest == nil { + return nil, reportError("tokenSetKeyRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.tokenSetKeyRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type ApiCoreTokensSetKeyCreateRequest struct { ctx context.Context ApiService *CoreAPIService diff --git a/packages/client-rust/src/apis/core_api.rs b/packages/client-rust/src/apis/core_api.rs index 91f34c23017c..062376257316 100644 --- a/packages/client-rust/src/apis/core_api.rs +++ b/packages/client-rust/src/apis/core_api.rs @@ -401,6 +401,15 @@ pub enum CoreTokensRotateCreateError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`core_tokens_session_create`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreTokensSessionCreateError { + Status400(), + Status403(), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`core_tokens_set_key_create`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -3572,6 +3581,46 @@ pub async fn core_tokens_rotate_create( } } +/// Exchange an agent's API token for an authenticated session. Only valid for active agent users +/// with non-expired INTENT_API tokens. +pub async fn core_tokens_session_create( + configuration: &configuration::Configuration, + token_set_key_request: models::TokenSetKeyRequest, +) -> Result<(), Error> { + // add a prefix to parameters to efficiently prevent name collisions + let p_body_token_set_key_request = token_set_key_request; + + let uri_str = format!("{}/core/tokens/session/", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_body_token_set_key_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + if !status.is_client_error() && !status.is_server_error() { + Ok(()) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// Set token key. Action is logged as event. `authentik_core.set_token_key` permission is required. pub async fn core_tokens_set_key_create( configuration: &configuration::Configuration, diff --git a/packages/client-ts/src/apis/CoreApi.ts b/packages/client-ts/src/apis/CoreApi.ts index 02a011be1f29..5256eefe8ba8 100644 --- a/packages/client-ts/src/apis/CoreApi.ts +++ b/packages/client-ts/src/apis/CoreApi.ts @@ -370,6 +370,10 @@ export interface CoreTokensRotateCreateRequest { identifier: string; } +export interface CoreTokensSessionCreateRequest { + tokenSetKeyRequest: TokenSetKeyRequest; +} + export interface CoreTokensSetKeyCreateRequest { identifier: string; tokenSetKeyRequest: TokenSetKeyRequest; @@ -3660,6 +3664,68 @@ export class CoreApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for coreTokensSessionCreate without sending the request + */ + async coreTokensSessionCreateRequestOpts( + requestParameters: CoreTokensSessionCreateRequest, + ): Promise { + if (requestParameters["tokenSetKeyRequest"] == null) { + throw new runtime.RequiredError( + "tokenSetKeyRequest", + 'Required parameter "tokenSetKeyRequest" was null or undefined when calling coreTokensSessionCreate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/tokens/session/`; + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: TokenSetKeyRequestToJSON(requestParameters["tokenSetKeyRequest"]), + }; + } + + /** + * Exchange an agent\'s API token for an authenticated session. Only valid for active agent users with non-expired INTENT_API tokens. + */ + async coreTokensSessionCreateRaw( + requestParameters: CoreTokensSessionCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.coreTokensSessionCreateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * Exchange an agent\'s API token for an authenticated session. Only valid for active agent users with non-expired INTENT_API tokens. + */ + async coreTokensSessionCreate( + requestParameters: CoreTokensSessionCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + await this.coreTokensSessionCreateRaw(requestParameters, initOverrides); + } + /** * Creates request options for coreTokensSetKeyCreate without sending the request */ diff --git a/schema.yml b/schema.yml index 24e2123aca97..c494b4a4aaf4 100644 --- a/schema.yml +++ b/schema.yml @@ -4008,6 +4008,29 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/tokens/session/: + post: + operationId: core_tokens_session_create + description: |- + Exchange an agent's API token for an authenticated session. Only valid for + active agent users with non-expired INTENT_API tokens. + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenSetKeyRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: Session created, session cookie set + '400': + description: Invalid token or not an agent user + '403': + description: Token expired or agent inactive /core/transactional/applications/: put: operationId: core_transactional_applications_update From dd9c5cde6ea006db67336b6d52932a9539e932c7 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 10:42:20 -0400 Subject: [PATCH 03/23] check for anonymous user --- authentik/policies/engine.py | 2 +- authentik/policies/tests/test_engine.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py index 96128a109f86..8f49e62abf1b 100644 --- a/authentik/policies/engine.py +++ b/authentik/policies/engine.py @@ -214,7 +214,7 @@ def _check_agent_access(self) -> PolicyResult | None: ) user = self.request.user - if user.type != UserTypes.AGENT: + if not hasattr(user, "type") or user.type != UserTypes.AGENT: return None if not isinstance(self.__pbm, Application): return None diff --git a/authentik/policies/tests/test_engine.py b/authentik/policies/tests/test_engine.py index 9a32d188eec6..c50a56a9dcee 100644 --- a/authentik/policies/tests/test_engine.py +++ b/authentik/policies/tests/test_engine.py @@ -218,6 +218,17 @@ def test_engine_group_complex(self): self.assertLess(ctx.final_queries, 1000) self.assertTrue(engine.result.passing) + def test_anonymous_user(self): + """AnonymousUser (no type attribute) does not break policy evaluation""" + from django.contrib.auth.models import AnonymousUser + + pbm = PolicyBindingModel.objects.create() + engine = PolicyEngine(pbm, AnonymousUser()) + engine.empty_result = True + engine.use_cache = False + engine.build() + self.assertTrue(engine.passing) + class TestPolicyEngineAgent(TestCase): """PolicyEngine agent access enforcement tests""" From 8447fad9c8f6590c582e58e09164f86dcd71b1bd Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 10:48:34 -0400 Subject: [PATCH 04/23] enterprise gate on agent creation --- web/src/admin/users/ak-user-wizard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/admin/users/ak-user-wizard.ts b/web/src/admin/users/ak-user-wizard.ts index eb60f0c33669..baf7ea75d562 100644 --- a/web/src/admin/users/ak-user-wizard.ts +++ b/web/src/admin/users/ak-user-wizard.ts @@ -55,8 +55,9 @@ const DEFAULT_USER_TYPES: TypeCreate[] = [ modelName: UserTypeEnum.Agent, name: msg("Agent"), description: msg( - "Enterprise-only agent user owned by an internal user, with scoped application access.", + "Machine user owned by an internal user, with scoped application access.", ), + requiresEnterprise: true, }, { component: "ak-user-service-account-form", From 5ed8444840d1d9b3c2aefafcb5ae8e978a1b6e8f Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 12:33:18 -0400 Subject: [PATCH 05/23] owner add/remove application from agent access --- authentik/core/api/users.py | 111 ++++++++-- authentik/core/tests/test_users_api.py | 63 +++++- packages/client-go/api_core.go | 113 +++++++++++ ..._patched_user_agent_allowed_app_request.go | 191 ++++++++++++++++++ ...odel_user_agent_allowed_app_action_enum.go | 111 ++++++++++ .../model_user_agent_allowed_apps.go | 2 +- .../model_user_agent_allowed_apps_request.go | 2 +- packages/client-rust/src/apis/core_api.rs | 77 +++++++ packages/client-rust/src/models/mod.rs | 4 + .../patched_user_agent_allowed_app_request.rs | 30 +++ .../user_agent_allowed_app_action_enum.rs | 35 ++++ .../src/models/user_agent_allowed_apps.rs | 4 +- .../models/user_agent_allowed_apps_request.rs | 4 +- packages/client-ts/src/apis/CoreApi.ts | 86 ++++++++ .../PatchedUserAgentAllowedAppRequest.ts | 90 +++++++++ .../models/UserAgentAllowedAppActionEnum.ts | 63 ++++++ .../src/models/UserAgentAllowedApps.ts | 2 +- .../src/models/UserAgentAllowedAppsRequest.ts | 2 +- packages/client-ts/src/models/index.ts | 2 + schema.yml | 53 ++++- web/src/admin/users/UserApplicationTable.ts | 115 ++++++++++- 21 files changed, 1125 insertions(+), 35 deletions(-) create mode 100644 packages/client-go/model_patched_user_agent_allowed_app_request.go create mode 100644 packages/client-go/model_user_agent_allowed_app_action_enum.go create mode 100644 packages/client-rust/src/models/patched_user_agent_allowed_app_request.rs create mode 100644 packages/client-rust/src/models/user_agent_allowed_app_action_enum.rs create mode 100644 packages/client-ts/src/models/PatchedUserAgentAllowedAppRequest.ts create mode 100644 packages/client-ts/src/models/UserAgentAllowedAppActionEnum.ts diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index ce233f3849ed..30ca0b0751c2 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -435,11 +435,18 @@ class UserAgentSerializer(PassiveSerializer): class UserAgentAllowedAppsSerializer(PassiveSerializer): - """Payload to update an agent's allowed applications""" + """Payload to replace an agent's allowed applications""" allowed_apps = ListField(child=UUIDField()) +class UserAgentAllowedAppSerializer(PassiveSerializer): + """Payload to add or remove a single allowed application""" + + app = UUIDField() + action = ChoiceField(choices=[("add", "Add"), ("remove", "Remove")]) + + class UserRecoveryLinkSerializer(PassiveSerializer): """Payload to create a recovery link""" @@ -861,26 +868,7 @@ def agent_allowed_apps( from authentik.core.apps import AppAccessWithoutBindings from authentik.policies.engine import PolicyEngine - agent: User = self.get_object() - - if agent.type != UserTypes.AGENT: - return Response( - data={"non_field_errors": [_("User is not an agent user.")]}, - status=400, - ) - - owner_pk = agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) - is_owner = str(request.user.pk) == owner_pk - if not request.user.is_superuser and not is_owner: - return Response(status=403) - - try: - owner = User.objects.get(pk=owner_pk) - except User.DoesNotExist: - return Response( - data={"non_field_errors": [_("Agent owner not found.")]}, - status=400, - ) + agent, owner = self._get_agent_and_owner(request) app_uuids = body.validated_data["allowed_apps"] errors = [] @@ -916,6 +904,87 @@ def agent_allowed_apps( agent.save(update_fields=["attributes"]) return Response({"allowed_apps": [str(u) for u in app_uuids]}) + @extend_schema( + request=UserAgentAllowedAppSerializer, + responses={ + 200: UserAgentAllowedAppsSerializer, + 204: OpenApiResponse(description="Application removed"), + 400: OpenApiResponse(description="Invalid app UUID or owner lacks access"), + 403: OpenApiResponse(description="Not the agent's owner or superuser"), + }, + ) + @action( + detail=True, + methods=["PATCH"], + url_path="agent_allowed_app", + url_name="agent-allowed-app", + pagination_class=None, + filter_backends=[], + ) + @validate(UserAgentAllowedAppSerializer) + def agent_allowed_app( + self, request: Request, pk: int, body: UserAgentAllowedAppSerializer + ) -> Response: + """Add or remove a single application from an agent's allowed list. + Caller must be the agent's owner or a superuser.""" + from authentik.core.apps import AppAccessWithoutBindings + from authentik.policies.engine import PolicyEngine + + agent, owner = self._get_agent_and_owner(request) + + app_uuid = str(body.validated_data["app"]) + action = body.validated_data["action"] + current = agent.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS, []) + + if action == "add": + try: + app = Application.objects.get(pk=app_uuid) + except Application.DoesNotExist: + return Response( + data={"app": [_("Application does not exist.")]}, + status=400, + ) + engine = PolicyEngine(app, owner, request) + engine.empty_result = AppAccessWithoutBindings.get() + engine.use_cache = False + engine.build() + if not engine.passing: + return Response( + data={"app": [_("Owner does not have access to this application.")]}, + status=400, + ) + if app_uuid not in current: + current.append(app_uuid) + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS] = current + agent.save(update_fields=["attributes"]) + return Response({"allowed_apps": current}) + + if action == "remove": + if app_uuid in current: + current.remove(app_uuid) + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS] = current + agent.save(update_fields=["attributes"]) + return Response(status=204) + + def _get_agent_and_owner(self, request: Request) -> tuple[User, User]: + """Validate that the target is an agent and the caller is authorized.""" + agent: User = self.get_object() + + if agent.type != UserTypes.AGENT: + raise ValidationError(_("User is not an agent user.")) + + owner_pk = agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) + is_owner = str(request.user.pk) == owner_pk + if not request.user.is_superuser and not is_owner: + raise ValidationError(_("Not the agent's owner or superuser.")) + + try: + owner = User.objects.get(pk=owner_pk) + except User.DoesNotExist: + raise ValidationError(_("Agent owner not found.")) + + return agent, owner + @extend_schema(responses={200: SessionUserSerializer(many=False)}) @action( url_path="me", diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 8095e011ed48..20772c099a43 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1016,7 +1016,7 @@ def test_agent_allowed_apps_update(self): self.assertIn(str(app.pk), agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS]) def test_agent_allowed_apps_update_unauthorized(self): - """Non-owner cannot update the agent's allowed apps list""" + """Non-owner, non-superuser is rejected when updating allowed apps""" other = create_test_user() agent = self._create_agent(owner=other) self.client.force_login(self.admin) @@ -1025,7 +1025,7 @@ def test_agent_allowed_apps_update_unauthorized(self): data={"allowed_apps": []}, content_type="application/json", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 400) def test_agent_allowed_apps_update_non_agent(self): """Endpoint rejects non-agent users""" @@ -1037,6 +1037,65 @@ def test_agent_allowed_apps_update_non_agent(self): ) self.assertEqual(response.status_code, 400) + def test_agent_allowed_app_add(self): + """PATCH add: owner can add a single app to agent's allowed list""" + agent = self._create_agent(owner=self.admin) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + self.client.force_login(self.admin) + response = self.client.patch( + reverse("authentik_api:user-agent-allowed-app", kwargs={"pk": agent.pk}), + data={"app": str(app.pk), "action": "add"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + agent.refresh_from_db() + self.assertIn(str(app.pk), agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS]) + + def test_agent_allowed_app_add_duplicate(self): + """PATCH add: adding an already-allowed app is idempotent""" + agent = self._create_agent(owner=self.admin) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS] = [str(app.pk)] + agent.save(update_fields=["attributes"]) + self.client.force_login(self.admin) + response = self.client.patch( + reverse("authentik_api:user-agent-allowed-app", kwargs={"pk": agent.pk}), + data={"app": str(app.pk), "action": "add"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + agent.refresh_from_db() + self.assertEqual( + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS].count(str(app.pk)), 1 + ) + + def test_agent_allowed_app_remove(self): + """PATCH remove: owner can remove a single app from agent's allowed list""" + agent = self._create_agent(owner=self.admin) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS] = [str(app.pk)] + agent.save(update_fields=["attributes"]) + self.client.force_login(self.admin) + response = self.client.patch( + reverse("authentik_api:user-agent-allowed-app", kwargs={"pk": agent.pk}), + data={"app": str(app.pk), "action": "remove"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 204) + agent.refresh_from_db() + self.assertNotIn(str(app.pk), agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS]) + + def test_agent_allowed_app_add_nonexistent(self): + """PATCH add: nonexistent app UUID is rejected""" + agent = self._create_agent(owner=self.admin) + self.client.force_login(self.admin) + response = self.client.patch( + reverse("authentik_api:user-agent-allowed-app", kwargs={"pk": agent.pk}), + data={"app": "00000000-0000-0000-0000-000000000000", "action": "add"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + def test_token_rotate_by_agent_owner(self): """Agent owner can rotate the agent's token""" agent = self._create_agent(owner=self.user) diff --git a/packages/client-go/api_core.go b/packages/client-go/api_core.go index 35a2ee637ad7..c0dc76161783 100644 --- a/packages/client-go/api_core.go +++ b/packages/client-go/api_core.go @@ -7455,6 +7455,119 @@ func (a *CoreAPIService) CoreUserConsentUsedByListExecute(r ApiCoreUserConsentUs return localVarReturnValue, localVarHTTPResponse, nil } +type ApiCoreUsersAgentAllowedAppPartialUpdateRequest struct { + ctx context.Context + ApiService *CoreAPIService + id int32 + patchedUserAgentAllowedAppRequest *PatchedUserAgentAllowedAppRequest +} + +func (r ApiCoreUsersAgentAllowedAppPartialUpdateRequest) PatchedUserAgentAllowedAppRequest(patchedUserAgentAllowedAppRequest PatchedUserAgentAllowedAppRequest) ApiCoreUsersAgentAllowedAppPartialUpdateRequest { + r.patchedUserAgentAllowedAppRequest = &patchedUserAgentAllowedAppRequest + return r +} + +func (r ApiCoreUsersAgentAllowedAppPartialUpdateRequest) Execute() (*UserAgentAllowedApps, *http.Response, error) { + return r.ApiService.CoreUsersAgentAllowedAppPartialUpdateExecute(r) +} + +/* +CoreUsersAgentAllowedAppPartialUpdate Method for CoreUsersAgentAllowedAppPartialUpdate + +Add or remove a single application from an agent's allowed list. +Caller must be the agent's owner or a superuser. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id A unique integer value identifying this User. + @return ApiCoreUsersAgentAllowedAppPartialUpdateRequest +*/ +func (a *CoreAPIService) CoreUsersAgentAllowedAppPartialUpdate(ctx context.Context, id int32) ApiCoreUsersAgentAllowedAppPartialUpdateRequest { + return ApiCoreUsersAgentAllowedAppPartialUpdateRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return UserAgentAllowedApps +func (a *CoreAPIService) CoreUsersAgentAllowedAppPartialUpdateExecute(r ApiCoreUsersAgentAllowedAppPartialUpdateRequest) (*UserAgentAllowedApps, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UserAgentAllowedApps + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreUsersAgentAllowedAppPartialUpdate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/users/{id}/agent_allowed_app/" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.patchedUserAgentAllowedAppRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiCoreUsersAgentAllowedAppsUpdateRequest struct { ctx context.Context ApiService *CoreAPIService diff --git a/packages/client-go/model_patched_user_agent_allowed_app_request.go b/packages/client-go/model_patched_user_agent_allowed_app_request.go new file mode 100644 index 000000000000..d0bfa0aa3bf9 --- /dev/null +++ b/packages/client-go/model_patched_user_agent_allowed_app_request.go @@ -0,0 +1,191 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" +) + +// checks if the PatchedUserAgentAllowedAppRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &PatchedUserAgentAllowedAppRequest{} + +// PatchedUserAgentAllowedAppRequest Payload to add or remove a single allowed application +type PatchedUserAgentAllowedAppRequest struct { + App *string `json:"app,omitempty"` + Action *UserAgentAllowedAppActionEnum `json:"action,omitempty"` + AdditionalProperties map[string]interface{} +} + +type _PatchedUserAgentAllowedAppRequest PatchedUserAgentAllowedAppRequest + +// NewPatchedUserAgentAllowedAppRequest instantiates a new PatchedUserAgentAllowedAppRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewPatchedUserAgentAllowedAppRequest() *PatchedUserAgentAllowedAppRequest { + this := PatchedUserAgentAllowedAppRequest{} + return &this +} + +// NewPatchedUserAgentAllowedAppRequestWithDefaults instantiates a new PatchedUserAgentAllowedAppRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewPatchedUserAgentAllowedAppRequestWithDefaults() *PatchedUserAgentAllowedAppRequest { + this := PatchedUserAgentAllowedAppRequest{} + return &this +} + +// GetApp returns the App field value if set, zero value otherwise. +func (o *PatchedUserAgentAllowedAppRequest) GetApp() string { + if o == nil || IsNil(o.App) { + var ret string + return ret + } + return *o.App +} + +// GetAppOk returns a tuple with the App field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PatchedUserAgentAllowedAppRequest) GetAppOk() (*string, bool) { + if o == nil || IsNil(o.App) { + return nil, false + } + return o.App, true +} + +// HasApp returns a boolean if a field has been set. +func (o *PatchedUserAgentAllowedAppRequest) HasApp() bool { + if o != nil && !IsNil(o.App) { + return true + } + + return false +} + +// SetApp gets a reference to the given string and assigns it to the App field. +func (o *PatchedUserAgentAllowedAppRequest) SetApp(v string) { + o.App = &v +} + +// GetAction returns the Action field value if set, zero value otherwise. +func (o *PatchedUserAgentAllowedAppRequest) GetAction() UserAgentAllowedAppActionEnum { + if o == nil || IsNil(o.Action) { + var ret UserAgentAllowedAppActionEnum + return ret + } + return *o.Action +} + +// GetActionOk returns a tuple with the Action field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PatchedUserAgentAllowedAppRequest) GetActionOk() (*UserAgentAllowedAppActionEnum, bool) { + if o == nil || IsNil(o.Action) { + return nil, false + } + return o.Action, true +} + +// HasAction returns a boolean if a field has been set. +func (o *PatchedUserAgentAllowedAppRequest) HasAction() bool { + if o != nil && !IsNil(o.Action) { + return true + } + + return false +} + +// SetAction gets a reference to the given UserAgentAllowedAppActionEnum and assigns it to the Action field. +func (o *PatchedUserAgentAllowedAppRequest) SetAction(v UserAgentAllowedAppActionEnum) { + o.Action = &v +} + +func (o PatchedUserAgentAllowedAppRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o PatchedUserAgentAllowedAppRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.App) { + toSerialize["app"] = o.App + } + if !IsNil(o.Action) { + toSerialize["action"] = o.Action + } + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *PatchedUserAgentAllowedAppRequest) UnmarshalJSON(data []byte) (err error) { + varPatchedUserAgentAllowedAppRequest := _PatchedUserAgentAllowedAppRequest{} + + err = json.Unmarshal(data, &varPatchedUserAgentAllowedAppRequest) + + if err != nil { + return err + } + + *o = PatchedUserAgentAllowedAppRequest(varPatchedUserAgentAllowedAppRequest) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "app") + delete(additionalProperties, "action") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullablePatchedUserAgentAllowedAppRequest struct { + value *PatchedUserAgentAllowedAppRequest + isSet bool +} + +func (v NullablePatchedUserAgentAllowedAppRequest) Get() *PatchedUserAgentAllowedAppRequest { + return v.value +} + +func (v *NullablePatchedUserAgentAllowedAppRequest) Set(val *PatchedUserAgentAllowedAppRequest) { + v.value = val + v.isSet = true +} + +func (v NullablePatchedUserAgentAllowedAppRequest) IsSet() bool { + return v.isSet +} + +func (v *NullablePatchedUserAgentAllowedAppRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullablePatchedUserAgentAllowedAppRequest(val *PatchedUserAgentAllowedAppRequest) *NullablePatchedUserAgentAllowedAppRequest { + return &NullablePatchedUserAgentAllowedAppRequest{value: val, isSet: true} +} + +func (v NullablePatchedUserAgentAllowedAppRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullablePatchedUserAgentAllowedAppRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_agent_allowed_app_action_enum.go b/packages/client-go/model_user_agent_allowed_app_action_enum.go new file mode 100644 index 000000000000..b06fa5b9a9e0 --- /dev/null +++ b/packages/client-go/model_user_agent_allowed_app_action_enum.go @@ -0,0 +1,111 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// UserAgentAllowedAppActionEnum the model 'UserAgentAllowedAppActionEnum' +type UserAgentAllowedAppActionEnum string + +// List of UserAgentAllowedAppActionEnum +const ( + USERAGENTALLOWEDAPPACTIONENUM_ADD UserAgentAllowedAppActionEnum = "add" + USERAGENTALLOWEDAPPACTIONENUM_REMOVE UserAgentAllowedAppActionEnum = "remove" +) + +// All allowed values of UserAgentAllowedAppActionEnum enum +var AllowedUserAgentAllowedAppActionEnumEnumValues = []UserAgentAllowedAppActionEnum{ + "add", + "remove", +} + +func (v *UserAgentAllowedAppActionEnum) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := UserAgentAllowedAppActionEnum(value) + for _, existing := range AllowedUserAgentAllowedAppActionEnumEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid UserAgentAllowedAppActionEnum", value) +} + +// NewUserAgentAllowedAppActionEnumFromValue returns a pointer to a valid UserAgentAllowedAppActionEnum +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewUserAgentAllowedAppActionEnumFromValue(v string) (*UserAgentAllowedAppActionEnum, error) { + ev := UserAgentAllowedAppActionEnum(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for UserAgentAllowedAppActionEnum: valid values are %v", v, AllowedUserAgentAllowedAppActionEnumEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v UserAgentAllowedAppActionEnum) IsValid() bool { + for _, existing := range AllowedUserAgentAllowedAppActionEnumEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to UserAgentAllowedAppActionEnum value +func (v UserAgentAllowedAppActionEnum) Ptr() *UserAgentAllowedAppActionEnum { + return &v +} + +type NullableUserAgentAllowedAppActionEnum struct { + value *UserAgentAllowedAppActionEnum + isSet bool +} + +func (v NullableUserAgentAllowedAppActionEnum) Get() *UserAgentAllowedAppActionEnum { + return v.value +} + +func (v *NullableUserAgentAllowedAppActionEnum) Set(val *UserAgentAllowedAppActionEnum) { + v.value = val + v.isSet = true +} + +func (v NullableUserAgentAllowedAppActionEnum) IsSet() bool { + return v.isSet +} + +func (v *NullableUserAgentAllowedAppActionEnum) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserAgentAllowedAppActionEnum(val *UserAgentAllowedAppActionEnum) *NullableUserAgentAllowedAppActionEnum { + return &NullableUserAgentAllowedAppActionEnum{value: val, isSet: true} +} + +func (v NullableUserAgentAllowedAppActionEnum) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserAgentAllowedAppActionEnum) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_user_agent_allowed_apps.go b/packages/client-go/model_user_agent_allowed_apps.go index 79a9951b638f..f6fcee8e286b 100644 --- a/packages/client-go/model_user_agent_allowed_apps.go +++ b/packages/client-go/model_user_agent_allowed_apps.go @@ -19,7 +19,7 @@ import ( // checks if the UserAgentAllowedApps type satisfies the MappedNullable interface at compile time var _ MappedNullable = &UserAgentAllowedApps{} -// UserAgentAllowedApps Payload to update an agent's allowed applications +// UserAgentAllowedApps Payload to replace an agent's allowed applications type UserAgentAllowedApps struct { AllowedApps []string `json:"allowed_apps"` AdditionalProperties map[string]interface{} diff --git a/packages/client-go/model_user_agent_allowed_apps_request.go b/packages/client-go/model_user_agent_allowed_apps_request.go index 93c05199e27f..4cc9e8591bdf 100644 --- a/packages/client-go/model_user_agent_allowed_apps_request.go +++ b/packages/client-go/model_user_agent_allowed_apps_request.go @@ -19,7 +19,7 @@ import ( // checks if the UserAgentAllowedAppsRequest type satisfies the MappedNullable interface at compile time var _ MappedNullable = &UserAgentAllowedAppsRequest{} -// UserAgentAllowedAppsRequest Payload to update an agent's allowed applications +// UserAgentAllowedAppsRequest Payload to replace an agent's allowed applications type UserAgentAllowedAppsRequest struct { AllowedApps []string `json:"allowed_apps"` AdditionalProperties map[string]interface{} diff --git a/packages/client-rust/src/apis/core_api.rs b/packages/client-rust/src/apis/core_api.rs index 062376257316..28d0c2b165db 100644 --- a/packages/client-rust/src/apis/core_api.rs +++ b/packages/client-rust/src/apis/core_api.rs @@ -493,6 +493,15 @@ pub enum CoreUserConsentUsedByListError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`core_users_agent_allowed_app_partial_update`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreUsersAgentAllowedAppPartialUpdateError { + Status400(), + Status403(), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`core_users_agent_allowed_apps_update`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -4164,6 +4173,74 @@ pub async fn core_user_consent_used_by_list( } } +/// Add or remove a single application from an agent's allowed list. Caller must be the agent's +/// owner or a superuser. +pub async fn core_users_agent_allowed_app_partial_update( + configuration: &configuration::Configuration, + id: i32, + patched_user_agent_allowed_app_request: Option, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_path_id = id; + let p_body_patched_user_agent_allowed_app_request = patched_user_agent_allowed_app_request; + + let uri_str = format!( + "{}/core/users/{id}/agent_allowed_app/", + configuration.base_path, + id = p_path_id + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::PATCH, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_body_patched_user_agent_allowed_app_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => { + return Err(Error::from(serde_json::Error::custom( + "Received `text/plain` content type response that cannot be converted to \ + `models::UserAgentAllowedApps`", + ))); + } + ContentType::Unsupported(unknown_type) => { + return Err(Error::from(serde_json::Error::custom(format!( + "Received `{unknown_type}` content type response that cannot be converted to \ + `models::UserAgentAllowedApps`" + )))); + } + } + } else { + let content = resp.text().await?; + let entity: Option = + serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// Replace the allowed application list for an agent user. Caller must be the agent's owner or a /// superuser. pub async fn core_users_agent_allowed_apps_update( diff --git a/packages/client-rust/src/models/mod.rs b/packages/client-rust/src/models/mod.rs index 0955672b1cbb..88ecd628b636 100644 --- a/packages/client-rust/src/models/mod.rs +++ b/packages/client-rust/src/models/mod.rs @@ -1252,6 +1252,8 @@ pub mod patched_totp_device_request; pub use self::patched_totp_device_request::PatchedTotpDeviceRequest; pub mod patched_unique_password_policy_request; pub use self::patched_unique_password_policy_request::PatchedUniquePasswordPolicyRequest; +pub mod patched_user_agent_allowed_app_request; +pub use self::patched_user_agent_allowed_app_request::PatchedUserAgentAllowedAppRequest; pub mod patched_user_delete_stage_request; pub use self::patched_user_delete_stage_request::PatchedUserDeleteStageRequest; pub mod patched_user_kerberos_source_connection_request; @@ -1634,6 +1636,8 @@ pub mod user_account_request; pub use self::user_account_request::UserAccountRequest; pub mod user_account_serializer_for_role_request; pub use self::user_account_serializer_for_role_request::UserAccountSerializerForRoleRequest; +pub mod user_agent_allowed_app_action_enum; +pub use self::user_agent_allowed_app_action_enum::UserAgentAllowedAppActionEnum; pub mod user_agent_allowed_apps; pub use self::user_agent_allowed_apps::UserAgentAllowedApps; pub mod user_agent_allowed_apps_request; diff --git a/packages/client-rust/src/models/patched_user_agent_allowed_app_request.rs b/packages/client-rust/src/models/patched_user_agent_allowed_app_request.rs new file mode 100644 index 000000000000..9a50776a55b0 --- /dev/null +++ b/packages/client-rust/src/models/patched_user_agent_allowed_app_request.rs @@ -0,0 +1,30 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// PatchedUserAgentAllowedAppRequest : Payload to add or remove a single allowed application +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct PatchedUserAgentAllowedAppRequest { + #[serde(rename = "app", skip_serializing_if = "Option::is_none")] + pub app: Option, + #[serde(rename = "action", skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +impl PatchedUserAgentAllowedAppRequest { + /// Payload to add or remove a single allowed application + pub fn new() -> PatchedUserAgentAllowedAppRequest { + PatchedUserAgentAllowedAppRequest { + app: None, + action: None, + } + } +} diff --git a/packages/client-rust/src/models/user_agent_allowed_app_action_enum.rs b/packages/client-rust/src/models/user_agent_allowed_app_action_enum.rs new file mode 100644 index 000000000000..657bad1a57be --- /dev/null +++ b/packages/client-rust/src/models/user_agent_allowed_app_action_enum.rs @@ -0,0 +1,35 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum UserAgentAllowedAppActionEnum { + #[serde(rename = "add")] + Add, + #[serde(rename = "remove")] + Remove, +} + +impl std::fmt::Display for UserAgentAllowedAppActionEnum { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Add => write!(f, "add"), + Self::Remove => write!(f, "remove"), + } + } +} + +impl Default for UserAgentAllowedAppActionEnum { + fn default() -> UserAgentAllowedAppActionEnum { + Self::Add + } +} diff --git a/packages/client-rust/src/models/user_agent_allowed_apps.rs b/packages/client-rust/src/models/user_agent_allowed_apps.rs index 1984504b1bac..a00af2ff84f5 100644 --- a/packages/client-rust/src/models/user_agent_allowed_apps.rs +++ b/packages/client-rust/src/models/user_agent_allowed_apps.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::models; -/// UserAgentAllowedApps : Payload to update an agent's allowed applications +/// UserAgentAllowedApps : Payload to replace an agent's allowed applications #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct UserAgentAllowedApps { #[serde(rename = "allowed_apps")] @@ -18,7 +18,7 @@ pub struct UserAgentAllowedApps { } impl UserAgentAllowedApps { - /// Payload to update an agent's allowed applications + /// Payload to replace an agent's allowed applications pub fn new(allowed_apps: Vec) -> UserAgentAllowedApps { UserAgentAllowedApps { allowed_apps } } diff --git a/packages/client-rust/src/models/user_agent_allowed_apps_request.rs b/packages/client-rust/src/models/user_agent_allowed_apps_request.rs index c9397c43cec4..cad4e9edafaf 100644 --- a/packages/client-rust/src/models/user_agent_allowed_apps_request.rs +++ b/packages/client-rust/src/models/user_agent_allowed_apps_request.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::models; -/// UserAgentAllowedAppsRequest : Payload to update an agent's allowed applications +/// UserAgentAllowedAppsRequest : Payload to replace an agent's allowed applications #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct UserAgentAllowedAppsRequest { #[serde(rename = "allowed_apps")] @@ -18,7 +18,7 @@ pub struct UserAgentAllowedAppsRequest { } impl UserAgentAllowedAppsRequest { - /// Payload to update an agent's allowed applications + /// Payload to replace an agent's allowed applications pub fn new(allowed_apps: Vec) -> UserAgentAllowedAppsRequest { UserAgentAllowedAppsRequest { allowed_apps } } diff --git a/packages/client-ts/src/apis/CoreApi.ts b/packages/client-ts/src/apis/CoreApi.ts index 5256eefe8ba8..e5eeaf7c41ce 100644 --- a/packages/client-ts/src/apis/CoreApi.ts +++ b/packages/client-ts/src/apis/CoreApi.ts @@ -41,6 +41,7 @@ import type { PatchedBrandRequest, PatchedGroupRequest, PatchedTokenRequest, + PatchedUserAgentAllowedAppRequest, PatchedUserRequest, PolicyTestResult, SessionUser, @@ -95,6 +96,7 @@ import { PatchedBrandRequestToJSON, PatchedGroupRequestToJSON, PatchedTokenRequestToJSON, + PatchedUserAgentAllowedAppRequestToJSON, PatchedUserRequestToJSON, PolicyTestResultFromJSON, SessionUserFromJSON, @@ -417,6 +419,11 @@ export interface CoreUserConsentUsedByListRequest { id: number; } +export interface CoreUsersAgentAllowedAppPartialUpdateRequest { + id: number; + patchedUserAgentAllowedAppRequest?: PatchedUserAgentAllowedAppRequest; +} + export interface CoreUsersAgentAllowedAppsUpdateRequest { id: number; userAgentAllowedAppsRequest: UserAgentAllowedAppsRequest; @@ -4333,6 +4340,85 @@ export class CoreApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for coreUsersAgentAllowedAppPartialUpdate without sending the request + */ + async coreUsersAgentAllowedAppPartialUpdateRequestOpts( + requestParameters: CoreUsersAgentAllowedAppPartialUpdateRequest, + ): Promise { + if (requestParameters["id"] == null) { + throw new runtime.RequiredError( + "id", + 'Required parameter "id" was null or undefined when calling coreUsersAgentAllowedAppPartialUpdate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/users/{id}/agent_allowed_app/`; + urlPath = urlPath.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters["id"]))); + + return { + path: urlPath, + method: "PATCH", + headers: headerParameters, + query: queryParameters, + body: PatchedUserAgentAllowedAppRequestToJSON( + requestParameters["patchedUserAgentAllowedAppRequest"], + ), + }; + } + + /** + * Add or remove a single application from an agent\'s allowed list. Caller must be the agent\'s owner or a superuser. + */ + async coreUsersAgentAllowedAppPartialUpdateRaw( + requestParameters: CoreUsersAgentAllowedAppPartialUpdateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = + await this.coreUsersAgentAllowedAppPartialUpdateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + UserAgentAllowedAppsFromJSON(jsonValue), + ); + } + + /** + * Add or remove a single application from an agent\'s allowed list. Caller must be the agent\'s owner or a superuser. + */ + async coreUsersAgentAllowedAppPartialUpdate( + requestParameters: CoreUsersAgentAllowedAppPartialUpdateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.coreUsersAgentAllowedAppPartialUpdateRaw( + requestParameters, + initOverrides, + ); + switch (response.raw.status) { + case 200: + return await response.value(); + case 204: + return null; + default: + return await response.value(); + } + } + /** * Creates request options for coreUsersAgentAllowedAppsUpdate without sending the request */ diff --git a/packages/client-ts/src/models/PatchedUserAgentAllowedAppRequest.ts b/packages/client-ts/src/models/PatchedUserAgentAllowedAppRequest.ts new file mode 100644 index 000000000000..3289a2ab1182 --- /dev/null +++ b/packages/client-ts/src/models/PatchedUserAgentAllowedAppRequest.ts @@ -0,0 +1,90 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { UserAgentAllowedAppActionEnum } from "./UserAgentAllowedAppActionEnum"; +import { + UserAgentAllowedAppActionEnumFromJSON, + UserAgentAllowedAppActionEnumToJSON, +} from "./UserAgentAllowedAppActionEnum"; + +/** + * Payload to add or remove a single allowed application + * @export + * @interface PatchedUserAgentAllowedAppRequest + */ +export interface PatchedUserAgentAllowedAppRequest { + /** + * + * @type {string} + * @memberof PatchedUserAgentAllowedAppRequest + */ + app?: string; + /** + * + * @type {UserAgentAllowedAppActionEnum} + * @memberof PatchedUserAgentAllowedAppRequest + */ + action?: UserAgentAllowedAppActionEnum; +} + +/** + * Check if a given object implements the PatchedUserAgentAllowedAppRequest interface. + */ +export function instanceOfPatchedUserAgentAllowedAppRequest( + value: object, +): value is PatchedUserAgentAllowedAppRequest { + return true; +} + +export function PatchedUserAgentAllowedAppRequestFromJSON( + json: any, +): PatchedUserAgentAllowedAppRequest { + return PatchedUserAgentAllowedAppRequestFromJSONTyped(json, false); +} + +export function PatchedUserAgentAllowedAppRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): PatchedUserAgentAllowedAppRequest { + if (json == null) { + return json; + } + return { + app: json["app"] == null ? undefined : json["app"], + action: + json["action"] == null + ? undefined + : UserAgentAllowedAppActionEnumFromJSON(json["action"]), + }; +} + +export function PatchedUserAgentAllowedAppRequestToJSON( + json: any, +): PatchedUserAgentAllowedAppRequest { + return PatchedUserAgentAllowedAppRequestToJSONTyped(json, false); +} + +export function PatchedUserAgentAllowedAppRequestToJSONTyped( + value?: PatchedUserAgentAllowedAppRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + app: value["app"], + action: UserAgentAllowedAppActionEnumToJSON(value["action"]), + }; +} diff --git a/packages/client-ts/src/models/UserAgentAllowedAppActionEnum.ts b/packages/client-ts/src/models/UserAgentAllowedAppActionEnum.ts new file mode 100644 index 000000000000..f66af2f6436c --- /dev/null +++ b/packages/client-ts/src/models/UserAgentAllowedAppActionEnum.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const UserAgentAllowedAppActionEnum = { + Add: "add", + Remove: "remove", + UnknownDefaultOpenApi: "11184809", +} as const; +export type UserAgentAllowedAppActionEnum = + (typeof UserAgentAllowedAppActionEnum)[keyof typeof UserAgentAllowedAppActionEnum]; + +export function instanceOfUserAgentAllowedAppActionEnum(value: any): boolean { + for (const key in UserAgentAllowedAppActionEnum) { + if (Object.prototype.hasOwnProperty.call(UserAgentAllowedAppActionEnum, key)) { + if ( + UserAgentAllowedAppActionEnum[key as keyof typeof UserAgentAllowedAppActionEnum] === + value + ) { + return true; + } + } + } + return false; +} + +export function UserAgentAllowedAppActionEnumFromJSON(json: any): UserAgentAllowedAppActionEnum { + return UserAgentAllowedAppActionEnumFromJSONTyped(json, false); +} + +export function UserAgentAllowedAppActionEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserAgentAllowedAppActionEnum { + return json as UserAgentAllowedAppActionEnum; +} + +export function UserAgentAllowedAppActionEnumToJSON( + value?: UserAgentAllowedAppActionEnum | null, +): any { + return value as any; +} + +export function UserAgentAllowedAppActionEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): UserAgentAllowedAppActionEnum { + return value as UserAgentAllowedAppActionEnum; +} diff --git a/packages/client-ts/src/models/UserAgentAllowedApps.ts b/packages/client-ts/src/models/UserAgentAllowedApps.ts index 2da5d3c12651..a9d554956949 100644 --- a/packages/client-ts/src/models/UserAgentAllowedApps.ts +++ b/packages/client-ts/src/models/UserAgentAllowedApps.ts @@ -13,7 +13,7 @@ */ /** - * Payload to update an agent's allowed applications + * Payload to replace an agent's allowed applications * @export * @interface UserAgentAllowedApps */ diff --git a/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts b/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts index 4d4588b5cfd9..43778b9cfc5b 100644 --- a/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts +++ b/packages/client-ts/src/models/UserAgentAllowedAppsRequest.ts @@ -13,7 +13,7 @@ */ /** - * Payload to update an agent's allowed applications + * Payload to replace an agent's allowed applications * @export * @interface UserAgentAllowedAppsRequest */ diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 655914a4ebb6..aaae779940f4 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -628,6 +628,7 @@ export * from "./PatchedTelegramSourceRequest"; export * from "./PatchedTenantRequest"; export * from "./PatchedTokenRequest"; export * from "./PatchedUniquePasswordPolicyRequest"; +export * from "./PatchedUserAgentAllowedAppRequest"; export * from "./PatchedUserDeleteStageRequest"; export * from "./PatchedUserKerberosSourceConnectionRequest"; export * from "./PatchedUserLDAPSourceConnectionRequest"; @@ -818,6 +819,7 @@ export * from "./UsedByActionEnum"; export * from "./User"; export * from "./UserAccountRequest"; export * from "./UserAccountSerializerForRoleRequest"; +export * from "./UserAgentAllowedAppActionEnum"; export * from "./UserAgentAllowedApps"; export * from "./UserAgentAllowedAppsRequest"; export * from "./UserAgentRequest"; diff --git a/schema.yml b/schema.yml index c494b4a4aaf4..f76322ff8fca 100644 --- a/schema.yml +++ b/schema.yml @@ -4456,6 +4456,41 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/users/{id}/agent_allowed_app/: + patch: + operationId: core_users_agent_allowed_app_partial_update + description: |- + Add or remove a single application from an agent's allowed list. + Caller must be the agent's owner or a superuser. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUserAgentAllowedAppRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserAgentAllowedApps' + description: '' + '204': + description: Application removed + '400': + description: Invalid app UUID or owner lacks access + '403': + description: Not the agent's owner or superuser /core/users/{id}/agent_allowed_apps/: put: operationId: core_users_agent_allowed_apps_update @@ -50732,6 +50767,15 @@ components: maximum: 2147483647 minimum: 0 description: Number of passwords to check against. + PatchedUserAgentAllowedAppRequest: + type: object + description: Payload to add or remove a single allowed application + properties: + app: + type: string + format: uuid + action: + $ref: '#/components/schemas/UserAgentAllowedAppActionEnum' PatchedUserDeleteStageRequest: type: object description: UserDeleteStage Serializer @@ -56911,9 +56955,14 @@ components: type: integer required: - pk + UserAgentAllowedAppActionEnum: + enum: + - add + - remove + type: string UserAgentAllowedApps: type: object - description: Payload to update an agent's allowed applications + description: Payload to replace an agent's allowed applications properties: allowed_apps: type: array @@ -56924,7 +56973,7 @@ components: - allowed_apps UserAgentAllowedAppsRequest: type: object - description: Payload to update an agent's allowed applications + description: Payload to replace an agent's allowed applications properties: allowed_apps: type: array diff --git a/web/src/admin/users/UserApplicationTable.ts b/web/src/admin/users/UserApplicationTable.ts index 2ee499f0abca..b206fea2dec5 100644 --- a/web/src/admin/users/UserApplicationTable.ts +++ b/web/src/admin/users/UserApplicationTable.ts @@ -1,20 +1,86 @@ import "#elements/AppIcon"; +import "#elements/forms/HorizontalFormElement"; +import "#elements/forms/SearchSelect/index"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { DEFAULT_CONFIG } from "#common/api/config"; +import { renderModal } from "#elements/dialogs"; +import { Form } from "#elements/forms/Form"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; import { ifPresent } from "#elements/utils/attributes"; import { applicationListStyle } from "#admin/applications/ApplicationListPage"; -import { Application, CoreApi, User } from "@goauthentik/api"; +import { + Application, + CoreApi, + CoreApplicationsListRequest, + User, + UserTypeEnum, +} from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { CSSResult, html, nothing } from "lit"; +import { CSSResult, TemplateResult, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; +const USER_ATTRIBUTE_AGENT_OWNER_PK = "goauthentik.io/agent/owner-pk"; + +@customElement("ak-agent-add-application-form") +export class AgentAddApplicationForm extends Form<{ app: string }> { + public override headline = msg("Add Application"); + public override submitLabel = msg("Add"); + + @property({ attribute: false }) + public agent: User | null = null; + + public override getSuccessMessage(): string { + return msg("Successfully added application."); + } + + async send(data: { app: string }): Promise<{ app: string }> { + if (!this.agent) throw new Error("Agent not set"); + await new CoreApi(DEFAULT_CONFIG).coreUsersAgentAllowedAppPartialUpdate({ + id: this.agent.pk, + patchedUserAgentAllowedAppRequest: { app: data.app, action: "add" }, + }); + return data; + } + + protected override renderForm(): TemplateResult { + const ownerPk = this.agent?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK]; + + return html` + => { + const args: CoreApplicationsListRequest = { + ordering: "name", + pageSize: 20, + forUser: ownerPk ? Number(ownerPk) : undefined, + }; + if (query) { + args.search = query; + } + const result = + await new CoreApi(DEFAULT_CONFIG).coreApplicationsList(args); + return result.results; + }} + .renderElement=${(app: Application): string => app.name} + .value=${(app: Application | undefined): string | undefined => app?.pk} + .renderDescription=${(app: Application): TemplateResult => + html`${app.group || msg("No group")}`} + > + + `; + } +} + @customElement("ak-user-application-table") export class UserApplicationTable extends Table { @property({ attribute: false }) @@ -22,6 +88,10 @@ export class UserApplicationTable extends Table { static styles: CSSResult[] = [...super.styles, applicationListStyle]; + private get isAgent(): boolean { + return this.user?.type === UserTypeEnum.Agent; + } + async apiEndpoint(): Promise> { return new CoreApi(DEFAULT_CONFIG).coreApplicationsList({ ...(await this.defaultEndpointConfig()), @@ -38,6 +108,36 @@ export class UserApplicationTable extends Table { [msg("Actions"), null, msg("Row Actions")], ]; + private async removeApplication(app: Application): Promise { + if (!this.user) return; + await new CoreApi(DEFAULT_CONFIG).coreUsersAgentAllowedAppPartialUpdate({ + id: this.user.pk, + patchedUserAgentAllowedAppRequest: { app: String(app.pk), action: "remove" }, + }); + this.fetch(); + } + + protected openAddApplicationModal = () => { + return renderModal( + html``, + ); + }; + + protected override renderToolbar(): SlottedTemplateResult { + if (!this.isAgent) { + return super.renderToolbar(); + } + return html` + ${super.renderToolbar()}`; + } + row(item: Application): SlottedTemplateResult[] { return [ html``, @@ -71,6 +171,16 @@ export class UserApplicationTable extends Table { ` : nothing} + ${this.isAgent + ? html`` + : nothing} `, ]; } @@ -79,5 +189,6 @@ export class UserApplicationTable extends Table { declare global { interface HTMLElementTagNameMap { "ak-user-application-table": UserApplicationTable; + "ak-agent-add-application-form": AgentAddApplicationForm; } } From 1687949d718a5494fca9261411db4d11599be24c Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 13:08:56 -0400 Subject: [PATCH 06/23] is this how to frontend --- .../admin/users/AgentAddApplicationForm.ts | 84 +++++++++++++++++++ web/src/admin/users/UserApplicationTable.ts | 77 ++--------------- 2 files changed, 91 insertions(+), 70 deletions(-) create mode 100644 web/src/admin/users/AgentAddApplicationForm.ts diff --git a/web/src/admin/users/AgentAddApplicationForm.ts b/web/src/admin/users/AgentAddApplicationForm.ts new file mode 100644 index 000000000000..2ebe87e62901 --- /dev/null +++ b/web/src/admin/users/AgentAddApplicationForm.ts @@ -0,0 +1,84 @@ +import "#elements/forms/HorizontalFormElement"; +import "#elements/forms/SearchSelect/index"; + +import { DEFAULT_CONFIG } from "#common/api/config"; + +import { Form } from "#elements/forms/Form"; + +import { + Application, + CoreApi, + CoreApplicationsListRequest, + User, +} from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +const USER_ATTRIBUTE_AGENT_OWNER_PK = "goauthentik.io/agent/owner-pk"; + +@customElement("ak-agent-add-application-form") +export class AgentAddApplicationForm extends Form<{ app: string }> { + public override headline = msg("Add Application"); + public override submitLabel = msg("Add"); + + @property({ attribute: false }) + public agent: User | null = null; + + public override getSuccessMessage(): string { + return msg("Successfully added application."); + } + + async send(data: { app: string }): Promise<{ app: string }> { + if (!this.agent) throw new Error("Agent not set"); + await new CoreApi(DEFAULT_CONFIG).coreUsersAgentAllowedAppPartialUpdate({ + id: this.agent.pk, + patchedUserAgentAllowedAppRequest: { app: data.app, action: "add" }, + }); + return data; + } + + protected override renderForm(): TemplateResult { + const ownerPk = this.agent?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK]; + + return html` + => { + const args: CoreApplicationsListRequest = { + ordering: "name", + pageSize: 20, + forUser: ownerPk ? Number(ownerPk) : undefined, + }; + if (query) { + args.search = query; + } + const result = + await new CoreApi(DEFAULT_CONFIG).coreApplicationsList(args); + return result.results; + }} + .renderElement=${(app: Application): string => { + return app.name; + }} + .value=${(app: Application | undefined): string | undefined => { + return app?.pk; + }} + .renderDescription=${(app: Application): TemplateResult => { + return html`${app.group || msg("No group")}`; + }} + > + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-agent-add-application-form": AgentAddApplicationForm; + } +} diff --git a/web/src/admin/users/UserApplicationTable.ts b/web/src/admin/users/UserApplicationTable.ts index b206fea2dec5..711acf5588bf 100644 --- a/web/src/admin/users/UserApplicationTable.ts +++ b/web/src/admin/users/UserApplicationTable.ts @@ -1,86 +1,22 @@ +import "#admin/users/AgentAddApplicationForm"; import "#elements/AppIcon"; -import "#elements/forms/HorizontalFormElement"; -import "#elements/forms/SearchSelect/index"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { DEFAULT_CONFIG } from "#common/api/config"; import { renderModal } from "#elements/dialogs"; -import { Form } from "#elements/forms/Form"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; import { ifPresent } from "#elements/utils/attributes"; import { applicationListStyle } from "#admin/applications/ApplicationListPage"; -import { - Application, - CoreApi, - CoreApplicationsListRequest, - User, - UserTypeEnum, -} from "@goauthentik/api"; +import { Application, CoreApi, User, UserTypeEnum } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, html, nothing } from "lit"; +import { CSSResult, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; -const USER_ATTRIBUTE_AGENT_OWNER_PK = "goauthentik.io/agent/owner-pk"; - -@customElement("ak-agent-add-application-form") -export class AgentAddApplicationForm extends Form<{ app: string }> { - public override headline = msg("Add Application"); - public override submitLabel = msg("Add"); - - @property({ attribute: false }) - public agent: User | null = null; - - public override getSuccessMessage(): string { - return msg("Successfully added application."); - } - - async send(data: { app: string }): Promise<{ app: string }> { - if (!this.agent) throw new Error("Agent not set"); - await new CoreApi(DEFAULT_CONFIG).coreUsersAgentAllowedAppPartialUpdate({ - id: this.agent.pk, - patchedUserAgentAllowedAppRequest: { app: data.app, action: "add" }, - }); - return data; - } - - protected override renderForm(): TemplateResult { - const ownerPk = this.agent?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK]; - - return html` - => { - const args: CoreApplicationsListRequest = { - ordering: "name", - pageSize: 20, - forUser: ownerPk ? Number(ownerPk) : undefined, - }; - if (query) { - args.search = query; - } - const result = - await new CoreApi(DEFAULT_CONFIG).coreApplicationsList(args); - return result.results; - }} - .renderElement=${(app: Application): string => app.name} - .value=${(app: Application | undefined): string | undefined => app?.pk} - .renderDescription=${(app: Application): TemplateResult => - html`${app.group || msg("No group")}`} - > - - `; - } -} - @customElement("ak-user-application-table") export class UserApplicationTable extends Table { @property({ attribute: false }) @@ -118,11 +54,13 @@ export class UserApplicationTable extends Table { } protected openAddApplicationModal = () => { - return renderModal( + renderModal( html``, - ); + ).then(() => { + this.fetch(); + }); }; protected override renderToolbar(): SlottedTemplateResult { @@ -189,6 +127,5 @@ export class UserApplicationTable extends Table { declare global { interface HTMLElementTagNameMap { "ak-user-application-table": UserApplicationTable; - "ak-agent-add-application-form": AgentAddApplicationForm; } } From 4e09e82b2f819133681bf94f502a279bcb43b0db Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 13:40:53 -0400 Subject: [PATCH 07/23] agent token session API view --- authentik/core/api/tokens.py | 49 ----- authentik/core/tests/test_token_api.py | 68 ------- authentik/core/urls.py | 6 + packages/client-go/api_core.go | 209 +++++++++++----------- packages/client-rust/src/apis/core_api.rs | 89 +++++---- packages/client-ts/src/apis/CoreApi.ts | 105 ++++------- schema.yml | 38 ++-- 7 files changed, 209 insertions(+), 355 deletions(-) diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index 70535df1c0e0..aaccd5639b61 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -3,7 +3,6 @@ from datetime import timedelta from typing import Any -from django.contrib.auth import login from django.utils.timezone import now from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.decorators import action @@ -205,54 +204,6 @@ def rotate(self, request: Request, identifier: str) -> Response: Event.new(EventAction.SECRET_ROTATE, secret=token).from_http(request) # noqa # nosec return Response(TokenViewSerializer({"key": token.key}).data) - @extend_schema( - request=TokenSetKeySerializer, - responses={ - 204: OpenApiResponse(description="Session created, session cookie set"), - 400: OpenApiResponse(description="Invalid token or not an agent user"), - 403: OpenApiResponse(description="Token expired or agent inactive"), - }, - ) - @action(detail=False, pagination_class=None, filter_backends=[], methods=["POST"]) - @validate(TokenSetKeySerializer) - def session(self, request: Request, body: TokenSetKeySerializer) -> Response: - """Exchange an agent's API token for an authenticated session. Only valid for - active agent users with non-expired INTENT_API tokens.""" - from authentik.core.models import AuthenticatedSession - from authentik.stages.password import BACKEND_INBUILT - - key = body.validated_data.get("key") - token = ( - Token.objects.filter(key=key, intent=TokenIntents.INTENT_API) - .select_related("user") - .first() - ) - if not token: - return Response( - data={"non_field_errors": ["Invalid token."]}, - status=400, - ) - if token.is_expired: - return Response( - data={"non_field_errors": ["Token has expired."]}, - status=403, - ) - if token.user.type != UserTypes.AGENT: - return Response( - data={"non_field_errors": ["Token does not belong to an agent user."]}, - status=400, - ) - if not token.user.is_active: - return Response( - data={"non_field_errors": ["Agent user is inactive."]}, - status=403, - ) - login(request._request, token.user, backend=BACKEND_INBUILT) - session = AuthenticatedSession.from_request(request._request, token.user) - if session: - session.save() - return Response(status=204) - @permission_required("authentik_core.set_token_key") @extend_schema( request=TokenSetKeySerializer(), diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py index ca7929dc1783..6a9a05a7b152 100644 --- a/authentik/core/tests/test_token_api.py +++ b/authentik/core/tests/test_token_api.py @@ -9,14 +9,10 @@ from authentik.core.api.tokens import TokenSerializer from authentik.core.models import ( - USER_ATTRIBUTE_AGENT_OWNER_PK, USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, - USER_PATH_AGENT, Token, TokenIntents, - User, - UserTypes, ) from authentik.core.tests.utils import create_test_admin_user, create_test_user from authentik.lib.generators import generate_id @@ -261,67 +257,3 @@ def test_serializer_no_request(self): ) -class TestTokenSession(APITestCase): - """Test token-to-session exchange""" - - def _create_agent_with_token(self): - owner = create_test_user() - agent = User.objects.create( - username=generate_id(), - type=UserTypes.AGENT, - attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)}, - path=USER_PATH_AGENT, - ) - agent.set_unusable_password() - agent.save() - token = Token.objects.create( - identifier=generate_id(), - intent=TokenIntents.INTENT_API, - user=agent, - expiring=True, - ) - return owner, agent, token - - def test_session_exchange_success(self): - """Valid agent token creates a session""" - _owner, _agent, token = self._create_agent_with_token() - response = self.client.post( - reverse("authentik_api:token-session"), - data={"key": token.key}, - ) - self.assertEqual(response.status_code, 204) - self.assertIn("authentik_session", response.cookies) - - def test_session_exchange_invalid_token(self): - """Invalid token key is rejected""" - response = self.client.post( - reverse("authentik_api:token-session"), - data={"key": "nonexistent-key"}, - ) - self.assertEqual(response.status_code, 400) - - def test_session_exchange_non_agent(self): - """Token belonging to a non-agent user is rejected""" - user = create_test_user() - token = Token.objects.create( - identifier=generate_id(), - intent=TokenIntents.INTENT_API, - user=user, - expiring=True, - ) - response = self.client.post( - reverse("authentik_api:token-session"), - data={"key": token.key}, - ) - self.assertEqual(response.status_code, 400) - - def test_session_exchange_inactive_agent(self): - """Inactive agent is rejected""" - _owner, agent, token = self._create_agent_with_token() - agent.is_active = False - agent.save(update_fields=["is_active"]) - response = self.client.post( - reverse("authentik_api:token-session"), - data={"key": token.key}, - ) - self.assertEqual(response.status_code, 403) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 7a97c1f379e8..e16dfc857926 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -19,6 +19,7 @@ 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.views.agent_session import AgentSessionView from authentik.core.views.apps import RedirectToAppLaunch from authentik.core.views.debug import AccessDeniedView from authentik.core.views.interface import ( @@ -79,6 +80,11 @@ TransactionalApplicationView.as_view(), name="core-transactional-application", ), + path( + "core/agent/session/", + AgentSessionView.as_view(), + name="agent-session", + ), ("core/groups", GroupViewSet), ("core/users", UserViewSet), ("core/tokens", TokenViewSet), diff --git a/packages/client-go/api_core.go b/packages/client-go/api_core.go index c0dc76161783..7b36915cdaa5 100644 --- a/packages/client-go/api_core.go +++ b/packages/client-go/api_core.go @@ -25,6 +25,115 @@ import ( // CoreAPIService CoreAPI service type CoreAPIService service +type ApiCoreAgentSessionCreateRequest struct { + ctx context.Context + ApiService *CoreAPIService +} + +func (r ApiCoreAgentSessionCreateRequest) Execute() (*http.Response, error) { + return r.ApiService.CoreAgentSessionCreateExecute(r) +} + +/* +CoreAgentSessionCreate Method for CoreAgentSessionCreate + +Exchange an agent's API token for an authenticated session. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiCoreAgentSessionCreateRequest +*/ +func (a *CoreAPIService) CoreAgentSessionCreate(ctx context.Context) ApiCoreAgentSessionCreateRequest { + return ApiCoreAgentSessionCreateRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *CoreAPIService) CoreAgentSessionCreateExecute(r ApiCoreAgentSessionCreateRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreAgentSessionCreate") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/core/agent/session/" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v GenericError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type ApiCoreApplicationEntitlementsCreateRequest struct { ctx context.Context ApiService *CoreAPIService @@ -6181,106 +6290,6 @@ func (a *CoreAPIService) CoreTokensRotateCreateExecute(r ApiCoreTokensRotateCrea return localVarReturnValue, localVarHTTPResponse, nil } -type ApiCoreTokensSessionCreateRequest struct { - ctx context.Context - ApiService *CoreAPIService - tokenSetKeyRequest *TokenSetKeyRequest -} - -func (r ApiCoreTokensSessionCreateRequest) TokenSetKeyRequest(tokenSetKeyRequest TokenSetKeyRequest) ApiCoreTokensSessionCreateRequest { - r.tokenSetKeyRequest = &tokenSetKeyRequest - return r -} - -func (r ApiCoreTokensSessionCreateRequest) Execute() (*http.Response, error) { - return r.ApiService.CoreTokensSessionCreateExecute(r) -} - -/* -CoreTokensSessionCreate Method for CoreTokensSessionCreate - -Exchange an agent's API token for an authenticated session. Only valid for -active agent users with non-expired INTENT_API tokens. - - @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - @return ApiCoreTokensSessionCreateRequest -*/ -func (a *CoreAPIService) CoreTokensSessionCreate(ctx context.Context) ApiCoreTokensSessionCreateRequest { - return ApiCoreTokensSessionCreateRequest{ - ApiService: a, - ctx: ctx, - } -} - -// Execute executes the request -func (a *CoreAPIService) CoreTokensSessionCreateExecute(r ApiCoreTokensSessionCreateRequest) (*http.Response, error) { - var ( - localVarHTTPMethod = http.MethodPost - localVarPostBody interface{} - formFiles []formFile - ) - - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CoreAPIService.CoreTokensSessionCreate") - if err != nil { - return nil, &GenericOpenAPIError{error: err.Error()} - } - - localVarPath := localBasePath + "/core/tokens/session/" - - localVarHeaderParams := make(map[string]string) - localVarQueryParams := url.Values{} - localVarFormParams := url.Values{} - if r.tokenSetKeyRequest == nil { - return nil, reportError("tokenSetKeyRequest is required and must be specified") - } - - // to determine the Content-Type header - localVarHTTPContentTypes := []string{"application/json"} - - // set Content-Type header - localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) - if localVarHTTPContentType != "" { - localVarHeaderParams["Content-Type"] = localVarHTTPContentType - } - - // to determine the Accept header - localVarHTTPHeaderAccepts := []string{} - - // set Accept header - localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) - if localVarHTTPHeaderAccept != "" { - localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept - } - // body params - localVarPostBody = r.tokenSetKeyRequest - req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) - if err != nil { - return nil, err - } - - localVarHTTPResponse, err := a.client.callAPI(req) - if err != nil || localVarHTTPResponse == nil { - return localVarHTTPResponse, err - } - - localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) - localVarHTTPResponse.Body.Close() - localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) - if err != nil { - return localVarHTTPResponse, err - } - - if localVarHTTPResponse.StatusCode >= 300 { - newErr := &GenericOpenAPIError{ - body: localVarBody, - error: localVarHTTPResponse.Status, - } - return localVarHTTPResponse, newErr - } - - return localVarHTTPResponse, nil -} - type ApiCoreTokensSetKeyCreateRequest struct { ctx context.Context ApiService *CoreAPIService diff --git a/packages/client-rust/src/apis/core_api.rs b/packages/client-rust/src/apis/core_api.rs index 28d0c2b165db..838a84a8078e 100644 --- a/packages/client-rust/src/apis/core_api.rs +++ b/packages/client-rust/src/apis/core_api.rs @@ -12,6 +12,15 @@ use serde::{Deserialize, Serialize, de::Error as _}; use super::{ContentType, Error, configuration}; use crate::{apis::ResponseContent, models}; +/// struct for typed errors of method [`core_agent_session_create`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoreAgentSessionCreateError { + Status400(models::ValidationError), + Status403(models::GenericError), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`core_application_entitlements_create`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -401,15 +410,6 @@ pub enum CoreTokensRotateCreateError { UnknownValue(serde_json::Value), } -/// struct for typed errors of method [`core_tokens_session_create`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CoreTokensSessionCreateError { - Status400(), - Status403(), - UnknownValue(serde_json::Value), -} - /// struct for typed errors of method [`core_tokens_set_key_create`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -664,6 +664,37 @@ pub enum CoreUsersUsedByListError { UnknownValue(serde_json::Value), } +/// Exchange an agent's API token for an authenticated session. +pub async fn core_agent_session_create( + configuration: &configuration::Configuration, +) -> Result<(), Error> { + let uri_str = format!("{}/core/agent/session/", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + if !status.is_client_error() && !status.is_server_error() { + Ok(()) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// ApplicationEntitlement Viewset pub async fn core_application_entitlements_create( configuration: &configuration::Configuration, @@ -3590,46 +3621,6 @@ pub async fn core_tokens_rotate_create( } } -/// Exchange an agent's API token for an authenticated session. Only valid for active agent users -/// with non-expired INTENT_API tokens. -pub async fn core_tokens_session_create( - configuration: &configuration::Configuration, - token_set_key_request: models::TokenSetKeyRequest, -) -> Result<(), Error> { - // add a prefix to parameters to efficiently prevent name collisions - let p_body_token_set_key_request = token_set_key_request; - - let uri_str = format!("{}/core/tokens/session/", configuration.base_path); - let mut req_builder = configuration - .client - .request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - if let Some(ref token) = configuration.bearer_access_token { - req_builder = req_builder.bearer_auth(token.to_owned()); - }; - req_builder = req_builder.json(&p_body_token_set_key_request); - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - - if !status.is_client_error() && !status.is_server_error() { - Ok(()) - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { - status, - content, - entity, - })) - } -} - /// Set token key. Action is logged as event. `authentik_core.set_token_key` permission is required. pub async fn core_tokens_set_key_create( configuration: &configuration::Configuration, diff --git a/packages/client-ts/src/apis/CoreApi.ts b/packages/client-ts/src/apis/CoreApi.ts index e5eeaf7c41ce..93056392e46c 100644 --- a/packages/client-ts/src/apis/CoreApi.ts +++ b/packages/client-ts/src/apis/CoreApi.ts @@ -372,10 +372,6 @@ export interface CoreTokensRotateCreateRequest { identifier: string; } -export interface CoreTokensSessionCreateRequest { - tokenSetKeyRequest: TokenSetKeyRequest; -} - export interface CoreTokensSetKeyCreateRequest { identifier: string; tokenSetKeyRequest: TokenSetKeyRequest; @@ -553,6 +549,45 @@ export interface CoreUsersUsedByListRequest { * */ export class CoreApi extends runtime.BaseAPI { + /** + * Creates request options for coreAgentSessionCreate without sending the request + */ + async coreAgentSessionCreateRequestOpts(): Promise { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + let urlPath = `/core/agent/session/`; + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * Exchange an agent\'s API token for an authenticated session. + */ + async coreAgentSessionCreateRaw( + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.coreAgentSessionCreateRequestOpts(); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * Exchange an agent\'s API token for an authenticated session. + */ + async coreAgentSessionCreate( + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + await this.coreAgentSessionCreateRaw(initOverrides); + } + /** * Creates request options for coreApplicationEntitlementsCreate without sending the request */ @@ -3671,68 +3706,6 @@ export class CoreApi extends runtime.BaseAPI { return await response.value(); } - /** - * Creates request options for coreTokensSessionCreate without sending the request - */ - async coreTokensSessionCreateRequestOpts( - requestParameters: CoreTokensSessionCreateRequest, - ): Promise { - if (requestParameters["tokenSetKeyRequest"] == null) { - throw new runtime.RequiredError( - "tokenSetKeyRequest", - 'Required parameter "tokenSetKeyRequest" was null or undefined when calling coreTokensSessionCreate().', - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - headerParameters["Content-Type"] = "application/json"; - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("authentik", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - - let urlPath = `/core/tokens/session/`; - - return { - path: urlPath, - method: "POST", - headers: headerParameters, - query: queryParameters, - body: TokenSetKeyRequestToJSON(requestParameters["tokenSetKeyRequest"]), - }; - } - - /** - * Exchange an agent\'s API token for an authenticated session. Only valid for active agent users with non-expired INTENT_API tokens. - */ - async coreTokensSessionCreateRaw( - requestParameters: CoreTokensSessionCreateRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction, - ): Promise> { - const requestOptions = await this.coreTokensSessionCreateRequestOpts(requestParameters); - const response = await this.request(requestOptions, initOverrides); - - return new runtime.VoidApiResponse(response); - } - - /** - * Exchange an agent\'s API token for an authenticated session. Only valid for active agent users with non-expired INTENT_API tokens. - */ - async coreTokensSessionCreate( - requestParameters: CoreTokensSessionCreateRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction, - ): Promise { - await this.coreTokensSessionCreateRaw(requestParameters, initOverrides); - } - /** * Creates request options for coreTokensSetKeyCreate without sending the request */ diff --git a/schema.yml b/schema.yml index f76322ff8fca..14496c16951d 100644 --- a/schema.yml +++ b/schema.yml @@ -2528,6 +2528,21 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/agent/session/: + post: + operationId: core_agent_session_create + description: Exchange an agent's API token for an authenticated session. + tags: + - core + security: + - {} + responses: + '200': + description: No response body + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /core/application_entitlements/: get: operationId: core_application_entitlements_list @@ -4008,29 +4023,6 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' - /core/tokens/session/: - post: - operationId: core_tokens_session_create - description: |- - Exchange an agent's API token for an authenticated session. Only valid for - active agent users with non-expired INTENT_API tokens. - tags: - - core - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/TokenSetKeyRequest' - required: true - security: - - authentik: [] - responses: - '204': - description: Session created, session cookie set - '400': - description: Invalid token or not an agent user - '403': - description: Token expired or agent inactive /core/transactional/applications/: put: operationId: core_transactional_applications_update From 7919569056fb6f5ddab364266fef073cac6838e5 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 15:34:08 -0400 Subject: [PATCH 08/23] add agent session view + tests --- authentik/core/tests/test_agent_session.py | 84 ++++++++++++++++++++++ authentik/core/views/agent_session.py | 44 ++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 authentik/core/tests/test_agent_session.py create mode 100644 authentik/core/views/agent_session.py diff --git a/authentik/core/tests/test_agent_session.py b/authentik/core/tests/test_agent_session.py new file mode 100644 index 000000000000..ccfa709f5557 --- /dev/null +++ b/authentik/core/tests/test_agent_session.py @@ -0,0 +1,84 @@ +"""Test agent token-to-session exchange""" + +from django.urls.base import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import ( + USER_ATTRIBUTE_AGENT_OWNER_PK, + USER_PATH_AGENT, + Token, + TokenIntents, + User, + UserTypes, +) +from authentik.core.tests.utils import create_test_user +from authentik.lib.generators import generate_id + + +class TestAgentSession(APITestCase): + """Test agent token-to-session exchange""" + + def _create_agent_with_token(self): + owner = create_test_user() + agent = User.objects.create( + username=generate_id(), + type=UserTypes.AGENT, + attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)}, + path=USER_PATH_AGENT, + ) + agent.set_unusable_password() + agent.save() + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=agent, + expiring=True, + ) + return owner, agent, token + + def test_session_exchange_success(self): + """Valid agent token creates a session""" + _owner, _agent, token = self._create_agent_with_token() + response = self.client.post( + reverse("authentik_api:agent-session"), + data={"key": token.key}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 204) + + def test_session_exchange_invalid_token(self): + """Invalid token key is rejected""" + response = self.client.post( + reverse("authentik_api:agent-session"), + data={"key": "nonexistent-key"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_session_exchange_non_agent(self): + """Token belonging to a non-agent user is rejected""" + user = create_test_user() + token = Token.objects.create( + identifier=generate_id(), + intent=TokenIntents.INTENT_API, + user=user, + expiring=True, + ) + response = self.client.post( + reverse("authentik_api:agent-session"), + data={"key": token.key}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_session_exchange_inactive_agent(self): + """Inactive agent is rejected""" + _owner, agent, token = self._create_agent_with_token() + agent.is_active = False + agent.save(update_fields=["is_active"]) + response = self.client.post( + reverse("authentik_api:agent-session"), + data={"key": token.key}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) diff --git a/authentik/core/views/agent_session.py b/authentik/core/views/agent_session.py new file mode 100644 index 000000000000..1f8793630ce6 --- /dev/null +++ b/authentik/core/views/agent_session.py @@ -0,0 +1,44 @@ +"""Agent token-to-session exchange view""" + +from django.contrib.auth import login +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentik.core.models import AuthenticatedSession, Token, TokenIntents, UserTypes +from authentik.stages.password import BACKEND_INBUILT + + +class AgentSessionView(APIView): + """Exchange an agent's API token for an authenticated session.""" + + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request: Request) -> Response: + key = request.data.get("key") + if not key: + return Response({"detail": "Key is required."}, status=400) + + token = ( + Token.objects.filter(key=key, intent=TokenIntents.INTENT_API) + .select_related("user") + .first() + ) + if not token: + return Response({"detail": "Invalid token."}, status=400) + if token.is_expired: + return Response({"detail": "Token has expired."}, status=403) + if token.user.type != UserTypes.AGENT: + return Response( + {"detail": "Token does not belong to an agent user."}, status=400 + ) + if not token.user.is_active: + return Response({"detail": "Agent user is inactive."}, status=403) + + login(request._request, token.user, backend=BACKEND_INBUILT) + session = AuthenticatedSession.from_request(request._request, token.user) + if session: + session.save() + return Response(status=204) From 77123ffce7f89249f0a3b4ea8fa1d64432724405 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 15:52:54 -0400 Subject: [PATCH 09/23] dedupe logic --- authentik/core/api/applications.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 0c54529f258b..e963f94568a6 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -26,13 +26,7 @@ from authentik.core.api.users import UserSerializer from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer from authentik.core.apps import AppAccessWithoutBindings -from authentik.core.models import ( - USER_ATTRIBUTE_AGENT_ALLOWED_APPS, - USER_ATTRIBUTE_AGENT_OWNER_PK, - Application, - User, - UserTypes, -) +from authentik.core.models import Application, User from authentik.events.logs import LogEventSerializer, capture_logs from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.engine import PolicyEngine @@ -170,29 +164,9 @@ def _get_allowed_applications( ) -> list[Application]: applications = [] request = self.request._request - check_user = user or request.user if user: request = copy(request) request.user = user - - if check_user.type == UserTypes.AGENT: - allowed_pks = set( - check_user.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS, []) - ) - owner_pk = check_user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) - owner = User.objects.filter(pk=owner_pk).first() if owner_pk else None - if not owner: - return [] - for application in paginated_apps: - if str(application.pk) not in allowed_pks: - continue - engine = PolicyEngine(application, owner, request) - engine.empty_result = AppAccessWithoutBindings.get() - engine.build() - if engine.passing: - applications.append(application) - return applications - for application in paginated_apps: engine = PolicyEngine(application, request.user, request) engine.empty_result = AppAccessWithoutBindings.get() From 1aa57f732b3f933cb2c1713c7db16fcf5977817d Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 15:56:15 -0400 Subject: [PATCH 10/23] explicit return --- authentik/core/api/users.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 30ca0b0751c2..8849bf3bf85f 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -966,6 +966,11 @@ def agent_allowed_app( agent.save(update_fields=["attributes"]) return Response(status=204) + return Response( + data={"action": [_("Invalid action.")]}, + status=400, + ) + def _get_agent_and_owner(self, request: Request) -> tuple[User, User]: """Validate that the target is an agent and the caller is authorized.""" agent: User = self.get_object() From 498ebe88404bd4ec648bfdf7261eb29345999e14 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 16:04:34 -0400 Subject: [PATCH 11/23] test fixes --- authentik/core/api/users.py | 2 ++ authentik/core/tests/test_users_api.py | 10 ++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 8849bf3bf85f..ed57b11d7088 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -797,6 +797,8 @@ def agent(self, request: Request, body: UserAgentSerializer) -> Response: expiring=True, ) user.assign_perms_to_managed_role("authentik_core.view_token_key", token) + owner.assign_perms_to_managed_role("authentik_core.view_token", token) + owner.assign_perms_to_managed_role("authentik_core.view_token_key", token) owner.assign_perms_to_managed_role("authentik_core.view_user", user) owner.assign_perms_to_managed_role("authentik_core.change_user", user) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 20772c099a43..2c8ea63a9c01 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -947,11 +947,9 @@ def test_agent_create_no_license(self): def test_agent_create_non_internal_user(self): """Only internal users can create agent users""" - external = create_test_user() - external.type = UserTypes.EXTERNAL - external.save() - external.assign_perms_to_managed_role("authentik_core.add_agent_user") - self.client.force_login(external) + self.admin.type = UserTypes.EXTERNAL + self.admin.save(update_fields=["type"]) + self.client.force_login(self.admin) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), @@ -1019,7 +1017,7 @@ def test_agent_allowed_apps_update_unauthorized(self): """Non-owner, non-superuser is rejected when updating allowed apps""" other = create_test_user() agent = self._create_agent(owner=other) - self.client.force_login(self.admin) + self.client.force_login(self.user) response = self.client.put( reverse("authentik_api:user-agent-allowed-apps", kwargs={"pk": agent.pk}), data={"allowed_apps": []}, From 0f3925bb4ea7b8122cee168e0ec15425cfbbfd46 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 16:08:23 -0400 Subject: [PATCH 12/23] auth in body --- authentik/core/views/agent_session.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/authentik/core/views/agent_session.py b/authentik/core/views/agent_session.py index 1f8793630ce6..cc6e32451524 100644 --- a/authentik/core/views/agent_session.py +++ b/authentik/core/views/agent_session.py @@ -1,6 +1,7 @@ """Agent token-to-session exchange view""" from django.contrib.auth import login +from rest_framework.authentication import BaseAuthentication from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response @@ -10,10 +11,17 @@ from authentik.stages.password import BACKEND_INBUILT +class NoAuthentication(BaseAuthentication): + """Explicitly skip DRF authentication; the view authenticates via the request body.""" + + def authenticate(self, request): + return None + + class AgentSessionView(APIView): """Exchange an agent's API token for an authenticated session.""" - authentication_classes = [] + authentication_classes = [NoAuthentication] permission_classes = [AllowAny] def post(self, request: Request) -> Response: From 3ce09b96d7afc34b16698cac588ce48975e71e0f Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 16:50:28 -0400 Subject: [PATCH 13/23] lint --- authentik/core/api/tokens.py | 8 +++----- authentik/core/api/users.py | 14 ++++---------- authentik/core/tests/test_token_api.py | 2 -- authentik/core/tests/test_users_api.py | 4 +--- authentik/core/views/agent_session.py | 4 +--- 5 files changed, 9 insertions(+), 23 deletions(-) diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index aaccd5639b61..58c212ab649b 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -190,11 +190,9 @@ def rotate(self, request: Request, identifier: str) -> Response: if not request.user.is_superuser: is_token_owner = token.user_id == request.user.pk - is_agent_owner = ( - token.user.type == UserTypes.AGENT - and str(request.user.pk) - == token.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) - ) + is_agent_owner = token.user.type == UserTypes.AGENT and str( + request.user.pk + ) == token.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK) if not is_token_owner and not is_agent_owner: return Response(status=403) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index ed57b11d7088..347477c1a7a6 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -803,9 +803,7 @@ def agent(self, request: Request, body: UserAgentSerializer) -> Response: owner.assign_perms_to_managed_role("authentik_core.view_user", user) owner.assign_perms_to_managed_role("authentik_core.change_user", user) owner.assign_perms_to_managed_role("authentik_core.delete_user", user) - owner.assign_perms_to_managed_role( - "authentik_core.view_user_applications", user - ) + owner.assign_perms_to_managed_role("authentik_core.view_user_applications", user) Event.new( EventAction.MODEL_CREATED, @@ -825,11 +823,7 @@ def agent(self, request: Request, body: UserAgentSerializer) -> Response: error_msg = str(exc).lower() if "unique" in error_msg: return Response( - data={ - "non_field_errors": [ - _("A user with this username already exists") - ] - }, + data={"non_field_errors": [_("A user with this username already exists")]}, status=400, ) else: @@ -987,8 +981,8 @@ def _get_agent_and_owner(self, request: Request) -> tuple[User, User]: try: owner = User.objects.get(pk=owner_pk) - except User.DoesNotExist: - raise ValidationError(_("Agent owner not found.")) + except User.DoesNotExist as exc: + raise ValidationError(_("Agent owner not found.")) from exc return agent, owner diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py index 6a9a05a7b152..75a579cf8a97 100644 --- a/authentik/core/tests/test_token_api.py +++ b/authentik/core/tests/test_token_api.py @@ -255,5 +255,3 @@ def test_serializer_no_request(self): } ).is_valid(raise_exception=True) ) - - diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 2c8ea63a9c01..d22f5b51979c 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1063,9 +1063,7 @@ def test_agent_allowed_app_add_duplicate(self): ) self.assertEqual(response.status_code, 200) agent.refresh_from_db() - self.assertEqual( - agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS].count(str(app.pk)), 1 - ) + self.assertEqual(agent.attributes[USER_ATTRIBUTE_AGENT_ALLOWED_APPS].count(str(app.pk)), 1) def test_agent_allowed_app_remove(self): """PATCH remove: owner can remove a single app from agent's allowed list""" diff --git a/authentik/core/views/agent_session.py b/authentik/core/views/agent_session.py index cc6e32451524..46f6742ac44c 100644 --- a/authentik/core/views/agent_session.py +++ b/authentik/core/views/agent_session.py @@ -39,9 +39,7 @@ def post(self, request: Request) -> Response: if token.is_expired: return Response({"detail": "Token has expired."}, status=403) if token.user.type != UserTypes.AGENT: - return Response( - {"detail": "Token does not belong to an agent user."}, status=400 - ) + return Response({"detail": "Token does not belong to an agent user."}, status=400) if not token.user.is_active: return Response({"detail": "Agent user is inactive."}, status=403) From b761fd8c6dbc0701dbed4346a00c0bebcf613d6b Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 16:55:31 -0400 Subject: [PATCH 14/23] remake web, gen --- .../admin/users/AgentAddApplicationForm.ts | 64 ++++++++----------- web/src/admin/users/UserApplicationTable.ts | 5 +- web/src/admin/users/ak-user-wizard.ts | 4 +- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/web/src/admin/users/AgentAddApplicationForm.ts b/web/src/admin/users/AgentAddApplicationForm.ts index 2ebe87e62901..5a3ff7535413 100644 --- a/web/src/admin/users/AgentAddApplicationForm.ts +++ b/web/src/admin/users/AgentAddApplicationForm.ts @@ -5,12 +5,7 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { Form } from "#elements/forms/Form"; -import { - Application, - CoreApi, - CoreApplicationsListRequest, - User, -} from "@goauthentik/api"; +import { Application, CoreApi, CoreApplicationsListRequest, User } from "@goauthentik/api"; import { msg } from "@lit/localize"; import { html, TemplateResult } from "lit"; @@ -42,38 +37,33 @@ export class AgentAddApplicationForm extends Form<{ app: string }> { protected override renderForm(): TemplateResult { const ownerPk = this.agent?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK]; - return html` + => { + const args: CoreApplicationsListRequest = { + ordering: "name", + pageSize: 20, + forUser: ownerPk ? Number(ownerPk) : undefined, + }; + if (query) { + args.search = query; + } + const result = await new CoreApi(DEFAULT_CONFIG).coreApplicationsList(args); + return result.results; + }} + .renderElement=${(app: Application): string => { + return app.name; + }} + .value=${(app: Application | undefined): string | undefined => { + return app?.pk; + }} + .renderDescription=${(app: Application): TemplateResult => { + return html`${app.group || msg("No group")}`; + }} > - => { - const args: CoreApplicationsListRequest = { - ordering: "name", - pageSize: 20, - forUser: ownerPk ? Number(ownerPk) : undefined, - }; - if (query) { - args.search = query; - } - const result = - await new CoreApi(DEFAULT_CONFIG).coreApplicationsList(args); - return result.results; - }} - .renderElement=${(app: Application): string => { - return app.name; - }} - .value=${(app: Application | undefined): string | undefined => { - return app?.pk; - }} - .renderDescription=${(app: Application): TemplateResult => { - return html`${app.group || msg("No group")}`; - }} - > - - `; + + `; } } diff --git a/web/src/admin/users/UserApplicationTable.ts b/web/src/admin/users/UserApplicationTable.ts index 711acf5588bf..0bab8ca37afc 100644 --- a/web/src/admin/users/UserApplicationTable.ts +++ b/web/src/admin/users/UserApplicationTable.ts @@ -67,10 +67,7 @@ export class UserApplicationTable extends Table { if (!this.isAgent) { return super.renderToolbar(); } - return html` ${super.renderToolbar()}`; diff --git a/web/src/admin/users/ak-user-wizard.ts b/web/src/admin/users/ak-user-wizard.ts index baf7ea75d562..c8c974390371 100644 --- a/web/src/admin/users/ak-user-wizard.ts +++ b/web/src/admin/users/ak-user-wizard.ts @@ -54,9 +54,7 @@ const DEFAULT_USER_TYPES: TypeCreate[] = [ component: "ak-user-agent-form", modelName: UserTypeEnum.Agent, name: msg("Agent"), - description: msg( - "Machine user owned by an internal user, with scoped application access.", - ), + description: msg("Machine user owned by an internal user, with scoped application access."), requiresEnterprise: true, }, { From 787cdfb8f29c0dad4cac4ef3fc040c7162235a8f Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 17:28:28 -0400 Subject: [PATCH 15/23] test fix --- authentik/core/tests/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/core/tests/test_users.py b/authentik/core/tests/test_users.py index 938534e49eee..9a23b5ebf619 100644 --- a/authentik/core/tests/test_users.py +++ b/authentik/core/tests/test_users.py @@ -119,7 +119,7 @@ def test_deactivate_owner_clears_agent_sessions(self): """Deactivating an owner removes authenticated sessions for their agents""" owner = self._create_owner() agent = self._create_agent(owner) - session = Session.objects.create(session_key=generate_id(), session_data="{}") + session = Session.objects.create(session_key=generate_id()) AuthenticatedSession.objects.create(user=agent, session=session) owner.is_active = False From 6222531f2d2b514a99760a81eb92787a4d473587 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 17:31:38 -0400 Subject: [PATCH 16/23] split migration --- ...058_alter_user_options_alter_user_type.py} | 31 +++++++++---------- .../0009_alter_userwritestage_user_type.py | 27 ++++++++++++++++ 2 files changed, 42 insertions(+), 16 deletions(-) rename authentik/core/migrations/{0058_user_agent_type.py => 0058_alter_user_options_alter_user_type.py} (93%) create mode 100644 authentik/stages/user_write/migrations/0009_alter_userwritestage_user_type.py diff --git a/authentik/core/migrations/0058_user_agent_type.py b/authentik/core/migrations/0058_alter_user_options_alter_user_type.py similarity index 93% rename from authentik/core/migrations/0058_user_agent_type.py rename to authentik/core/migrations/0058_alter_user_options_alter_user_type.py index 02532fd3f30e..65d17f40b18a 100644 --- a/authentik/core/migrations/0058_user_agent_type.py +++ b/authentik/core/migrations/0058_alter_user_options_alter_user_type.py @@ -1,4 +1,4 @@ -# Generated manually +# Generated by Django 5.2.13 on 2026-04-13 21:29 from django.db import migrations, models @@ -10,21 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterField( - model_name="user", - name="type", - field=models.CharField( - choices=[ - ("internal", "Internal"), - ("external", "External"), - ("service_account", "Service Account"), - ("internal_service_account", "Internal Service Account"), - ("agent", "Agent"), - ], - default="internal", - max_length=100, - ), - ), migrations.AlterModelOptions( name="user", options={ @@ -39,4 +24,18 @@ class Migration(migrations.Migration): "verbose_name_plural": "Users", }, ), + migrations.AlterField( + model_name="user", + name="type", + field=models.TextField( + choices=[ + ("internal", "Internal"), + ("external", "External"), + ("service_account", "Service Account"), + ("internal_service_account", "Internal Service Account"), + ("agent", "Agent"), + ], + default="internal", + ), + ), ] diff --git a/authentik/stages/user_write/migrations/0009_alter_userwritestage_user_type.py b/authentik/stages/user_write/migrations/0009_alter_userwritestage_user_type.py new file mode 100644 index 000000000000..e43a985ca548 --- /dev/null +++ b/authentik/stages/user_write/migrations/0009_alter_userwritestage_user_type.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.13 on 2026-04-13 21:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_user_write", "0008_userwritestage_user_type"), + ] + + operations = [ + migrations.AlterField( + model_name="userwritestage", + name="user_type", + field=models.TextField( + choices=[ + ("internal", "Internal"), + ("external", "External"), + ("service_account", "Service Account"), + ("internal_service_account", "Internal Service Account"), + ("agent", "Agent"), + ], + default="external", + ), + ), + ] From f495747b2a98c7a2eb522a9797280142cf6b6282 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 17:45:04 -0400 Subject: [PATCH 17/23] fix test --- authentik/core/tests/test_users.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/authentik/core/tests/test_users.py b/authentik/core/tests/test_users.py index 9a23b5ebf619..1d293c402e74 100644 --- a/authentik/core/tests/test_users.py +++ b/authentik/core/tests/test_users.py @@ -119,7 +119,11 @@ def test_deactivate_owner_clears_agent_sessions(self): """Deactivating an owner removes authenticated sessions for their agents""" owner = self._create_owner() agent = self._create_agent(owner) - session = Session.objects.create(session_key=generate_id()) + session = Session.objects.create( + session_key=generate_id(), + last_ip="255.255.255.255", + last_user_agent="", + ) AuthenticatedSession.objects.create(user=agent, session=session) owner.is_active = False From 685684c89a90f6b05f852d297435134f46a61e82 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 17:59:09 -0400 Subject: [PATCH 18/23] test fix, wish I could run these --- authentik/core/tests/test_users_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index d22f5b51979c..5a57c2257520 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1023,7 +1023,7 @@ def test_agent_allowed_apps_update_unauthorized(self): data={"allowed_apps": []}, content_type="application/json", ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) def test_agent_allowed_apps_update_non_agent(self): """Endpoint rejects non-agent users""" From 4647294541adf51aecdb17a4f8cc55c6fc49edb2 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 18:49:25 -0400 Subject: [PATCH 19/23] more realistic test --- authentik/core/tests/test_users_api.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 5a57c2257520..6dc389415927 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1094,15 +1094,21 @@ def test_agent_allowed_app_add_nonexistent(self): def test_token_rotate_by_agent_owner(self): """Agent owner can rotate the agent's token""" - agent = self._create_agent(owner=self.user) - token = Token.objects.create( - identifier=generate_id(), - intent=TokenIntents.INTENT_API, - user=agent, - expiring=True, + self.user.assign_perms_to_managed_role("authentik_core.add_agent_user") + self.client.force_login(self.user) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "rotate-test-agent"}, + ) + self.assertEqual(response.status_code, 200) + token = Token.objects.get( + user__username="rotate-test-agent", intent=TokenIntents.INTENT_API ) original_key = token.key - self.client.force_login(self.user) response = self.client.post( reverse("authentik_api:token-rotate", kwargs={"identifier": token.identifier}), ) From 014e0516c38dab994f2ba604ca95bff147076fd6 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 19:21:10 -0400 Subject: [PATCH 20/23] token lookup, no owner token viewing --- authentik/core/api/tokens.py | 9 ++++++++- authentik/core/api/users.py | 2 -- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index 58c212ab649b..3f6011eb69da 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -186,7 +186,14 @@ def view_key(self, request: Request, identifier: str) -> Response: def rotate(self, request: Request, identifier: str) -> Response: """Rotate the token key and reset the expiry to 24 hours. Only callable by the token owner, the owning agent's human owner, or a superuser.""" - token: Token = self.get_object() + token = ( + Token.objects.including_expired() + .select_related("user") + .filter(identifier=identifier) + .first() + ) + if not token: + return Response(status=404) if not request.user.is_superuser: is_token_owner = token.user_id == request.user.pk diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 347477c1a7a6..3d6d53aa73c2 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -797,8 +797,6 @@ def agent(self, request: Request, body: UserAgentSerializer) -> Response: expiring=True, ) user.assign_perms_to_managed_role("authentik_core.view_token_key", token) - owner.assign_perms_to_managed_role("authentik_core.view_token", token) - owner.assign_perms_to_managed_role("authentik_core.view_token_key", token) owner.assign_perms_to_managed_role("authentik_core.view_user", user) owner.assign_perms_to_managed_role("authentik_core.change_user", user) From 88c458ba3b7008112f3abbc962fc35c4a94240c0 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 20:22:53 -0400 Subject: [PATCH 21/23] owners must be admins, for now --- authentik/core/tests/test_users_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 6dc389415927..d3d4e9af1297 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1094,8 +1094,7 @@ def test_agent_allowed_app_add_nonexistent(self): def test_token_rotate_by_agent_owner(self): """Agent owner can rotate the agent's token""" - self.user.assign_perms_to_managed_role("authentik_core.add_agent_user") - self.client.force_login(self.user) + self.client.force_login(self.admin) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), From e939d60e18f5c1155139a8c58433946bfd7185df Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 20:44:21 -0400 Subject: [PATCH 22/23] explicit perms to add user + token, non-admin owners --- authentik/core/api/users.py | 4 ++- authentik/core/tests/test_users_api.py | 38 ++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 3d6d53aa73c2..bf05a91f7948 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -733,7 +733,9 @@ def service_account(self, request: Request, body: UserServiceAccountSerializer) status=500, ) - @permission_required(None, ["authentik_core.add_agent_user"]) + @permission_required( + None, ["authentik_core.add_user", "authentik_core.add_token", "authentik_core.add_agent_user"] + ) @extend_schema( request=UserAgentSerializer, responses={ diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index d3d4e9af1297..0384faabd9ed 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -891,8 +891,11 @@ class TestAgentUserAPI(APITestCase): def setUp(self) -> None: self.admin = create_test_admin_user() - self.admin.assign_perms_to_managed_role("authentik_core.add_agent_user") self.user = create_test_user() + self.owner = create_test_user() + self.owner.assign_perms_to_managed_role("authentik_core.add_agent_user") + self.owner.assign_perms_to_managed_role("authentik_core.add_user") + self.owner.assign_perms_to_managed_role("authentik_core.add_token") def _create_agent(self, name="test-agent", owner=None): owner = owner or self.admin @@ -911,8 +914,8 @@ def _create_agent(self, name="test-agent", owner=None): return agent def test_agent_create(self): - """Agent user creation""" - self.client.force_login(self.admin) + """Non-admin owner with correct permissions can create an agent""" + self.client.force_login(self.owner) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), @@ -925,7 +928,7 @@ def test_agent_create(self): agent = User.objects.get(username="test-agent") self.assertEqual(agent.type, UserTypes.AGENT) self.assertEqual(agent.path, USER_PATH_AGENT) - self.assertEqual(agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK), str(self.admin.pk)) + self.assertEqual(agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK), str(self.owner.pk)) self.assertEqual(agent.attributes.get(USER_ATTRIBUTE_AGENT_ALLOWED_APPS), []) self.assertFalse(agent.has_usable_password()) token = Token.objects.filter(user=agent, intent=TokenIntents.INTENT_API).first() @@ -934,7 +937,7 @@ def test_agent_create(self): def test_agent_create_no_license(self): """Agent creation is rejected without a valid enterprise license""" - self.client.force_login(self.admin) + self.client.force_login(self.owner) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=False))), @@ -947,9 +950,9 @@ def test_agent_create_no_license(self): def test_agent_create_non_internal_user(self): """Only internal users can create agent users""" - self.admin.type = UserTypes.EXTERNAL - self.admin.save(update_fields=["type"]) - self.client.force_login(self.admin) + self.owner.type = UserTypes.EXTERNAL + self.owner.save(update_fields=["type"]) + self.client.force_login(self.owner) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), @@ -960,10 +963,23 @@ def test_agent_create_non_internal_user(self): ) self.assertEqual(response.status_code, 400) + def test_agent_create_no_permission(self): + """User without add_agent_user permission is rejected""" + self.client.force_login(self.user) + with patch( + "authentik.enterprise.license.LicenseKey.cached_summary", + MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), + ): + response = self.client.post( + reverse("authentik_api:user-agent"), + data={"name": "test-agent"}, + ) + self.assertEqual(response.status_code, 403) + def test_agent_create_duplicate(self): """Duplicate agent username returns a user-friendly error""" self._create_agent("test-agent-dup") - self.client.force_login(self.admin) + self.client.force_login(self.owner) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), @@ -1093,8 +1109,8 @@ def test_agent_allowed_app_add_nonexistent(self): self.assertEqual(response.status_code, 400) def test_token_rotate_by_agent_owner(self): - """Agent owner can rotate the agent's token""" - self.client.force_login(self.admin) + """Non-admin owner can rotate the agent's token""" + self.client.force_login(self.owner) with patch( "authentik.enterprise.license.LicenseKey.cached_summary", MagicMock(return_value=MagicMock(status=MagicMock(is_valid=True))), From 6d22c39e8e2c7c6a2b9a1f0618676ce066dd2f29 Mon Sep 17 00:00:00 2001 From: Fletcher Heisler Date: Mon, 13 Apr 2026 21:33:20 -0400 Subject: [PATCH 23/23] lint --- authentik/core/api/users.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index bf05a91f7948..23b9c8add11c 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -734,7 +734,8 @@ def service_account(self, request: Request, body: UserServiceAccountSerializer) ) @permission_required( - None, ["authentik_core.add_user", "authentik_core.add_token", "authentik_core.add_agent_user"] + None, + ["authentik_core.add_user", "authentik_core.add_token", "authentik_core.add_agent_user"], ) @extend_schema( request=UserAgentSerializer,