Compare commits

...

2 Commits

Author SHA1 Message Date
Fletcher Heisler
36ca27a979 lint 2026-04-16 09:59:08 -04:00
Fletcher Heisler
a6b95c15db agent as internal user 2026-04-16 09:42:10 -04:00
43 changed files with 2727 additions and 62 deletions

View File

@@ -1,5 +1,6 @@
"""Tokens API Viewset"""
from datetime import timedelta
from typing import Any
from django.utils.timezone import now
@@ -18,12 +19,14 @@ from authentik.core.api.used_by import UsedByMixin
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,
default_token_duration,
default_token_key,
)
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
@@ -171,6 +174,40 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
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"),
404: OpenApiResponse(description="Token not found"),
},
)
@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.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
owner_pk = token.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
is_agent_owner = owner_pk and str(request.user.pk) == 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(),

View File

@@ -75,9 +75,12 @@ from authentik.core.middleware import (
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_SERVICE_ACCOUNT,
USERNAME_MAX_LENGTH,
Application,
Group,
Session,
Token,
@@ -88,6 +91,7 @@ from authentik.core.models import (
)
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 +253,28 @@ class UserSerializer(ModelSerializer):
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.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
and user_type != UserTypes.INTERNAL.value
):
raise ValidationError(_("Can't change agent user to non-internal type."))
return user_type
def validate_attributes(self, attrs: dict) -> dict:
"""Prevent removal of agent owner attribute (agents must keep their owner)"""
if not self.instance:
return attrs
existing_owner = self.instance.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
if not existing_owner:
return attrs
new_owner = attrs.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
if not new_owner:
raise ValidationError(_("Can't remove agent marker from agent user."))
if 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 +429,26 @@ 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 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"""
@@ -691,6 +735,265 @@ class UserViewSet(
status=500,
)
@permission_required(
None,
[
"authentik_core.add_user",
"authentik_core.add_token",
"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.
Agent users are internal users with an owner attribute that grants scoped
application access on behalf of the owner."""
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.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk),
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [],
},
)
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, owner = self._get_agent_and_owner(request)
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(
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)
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 user and the caller is authorized."""
agent: User = self.get_object()
owner_pk = agent.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
if not owner_pk:
raise ValidationError(_("User is not an agent user."))
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 as exc:
raise ValidationError(_("Agent owner not found.")) from exc
return agent, owner
@extend_schema(responses={200: SessionUserSerializer(many=False)})
@action(
url_path="me",

View File

@@ -1,6 +1,6 @@
"""Change user type"""
from authentik.core.models import User, UserTypes
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK, User, UserTypes
from authentik.tenants.management import TenantCommand
@@ -18,6 +18,7 @@ class Command(TenantCommand):
User.objects.exclude_anonymous()
.exclude(type=UserTypes.SERVICE_ACCOUNT)
.exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
)
if options["usernames"] and options["all"]:
self.stderr.write("--all and usernames specified, only one can be specified")

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.13 on 2026-04-16 12:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
]
operations = [
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",
},
),
]

View File

@@ -67,6 +67,9 @@ USER_ATTRIBUTE_CHANGE_USERNAME = f"{_USER_ATTR_PREFIX}/can-change-username"
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"
options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
# used_by API that allows models to specify if they shadow an object
@@ -385,6 +388,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
("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"]),

View File

@@ -11,6 +11,7 @@ from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.models import (
USER_ATTRIBUTE_AGENT_OWNER_PK,
Application,
AuthenticatedSession,
BackchannelProvider,
@@ -69,6 +70,34 @@ 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(
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"""

View File

@@ -0,0 +1,82 @@
"""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,
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.INTERNAL,
attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)},
)
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)

View File

@@ -199,6 +199,50 @@ class TestTokenAPI(APITestCase):
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(

View File

@@ -2,7 +2,13 @@
from django.test.testcases import TestCase
from authentik.core.models import User
from authentik.core.models import (
USER_ATTRIBUTE_AGENT_OWNER_PK,
AuthenticatedSession,
Session,
User,
UserTypes,
)
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -33,3 +39,92 @@ class TestUsers(TestCase):
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.INTERNAL,
attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)},
)
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(),
last_ip="255.255.255.255",
last_user_agent="",
)
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())

View File

@@ -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,14 @@ from rest_framework.test import APITestCase
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,
Application,
AuthenticatedSession,
Session,
Token,
TokenIntents,
User,
UserTypes,
)
@@ -878,3 +883,244 @@ class TestUsersAPI(APITestCase):
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.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
agent = User.objects.create(
username=name,
name=name,
type=UserTypes.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk),
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [],
},
)
agent.set_unusable_password()
agent.save()
return agent
def test_agent_create(self):
"""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))),
):
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.INTERNAL)
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()
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.owner)
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"""
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))),
):
response = self.client.post(
reverse("authentik_api:user-agent"),
data={"name": "test-agent"},
)
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.owner)
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.EXTERNAL},
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_marker_cannot_be_removed(self):
"""Removing the agent owner attribute is rejected"""
agent = self._create_agent()
self.client.force_login(self.admin)
new_attrs = dict(agent.attributes)
del new_attrs[USER_ATTRIBUTE_AGENT_OWNER_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, non-superuser is rejected when updating allowed apps"""
other = create_test_user()
agent = self._create_agent(owner=other)
self.client.force_login(self.user)
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_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_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):
"""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))),
):
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
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)

View File

@@ -19,6 +19,7 @@ from authentik.core.api.sources import (
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 @@ api_urlpatterns = [
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),

View File

@@ -0,0 +1,55 @@
"""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
from rest_framework.views import APIView
from authentik.core.models import (
USER_ATTRIBUTE_AGENT_OWNER_PK,
AuthenticatedSession,
Token,
TokenIntents,
)
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 = [NoAuthentication]
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 not token.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK):
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)

View File

@@ -14,7 +14,7 @@ from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.brands.models import Brand
from authentik.core.models import UserTypes
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK, UserTypes
from authentik.lib.config import CONFIG
from authentik.policies.denied import AccessDeniedResponse
@@ -26,10 +26,14 @@ class RootRedirectView(RedirectView):
query_string = True
def redirect_to_app(self, request: HttpRequest):
if request.user.is_authenticated and request.user.type in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
if request.user.is_authenticated and (
request.user.type
in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
)
or request.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
):
brand: Brand = request.brand
if brand.default_application:
@@ -66,10 +70,14 @@ class BrandDefaultRedirectView(InterfaceView):
"""By default redirect to default app"""
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if request.user.is_authenticated and request.user.type in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
if request.user.is_authenticated and (
request.user.type
in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
)
or request.user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
):
brand: Brand = request.brand
if brand.default_application:

View File

@@ -144,10 +144,14 @@ class LicenseViewSet(UsedByMixin, ModelViewSet):
def forecast(self, request: Request) -> Response:
"""Forecast how many users will be required in a year"""
last_month = now() - timedelta(days=30)
# Forecast for internal users
internal_in_last_month = User.objects.filter(
type=UserTypes.INTERNAL, date_joined__gte=last_month
).count()
# Forecast for internal users (excluding agents)
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK
internal_in_last_month = (
User.objects.filter(type=UserTypes.INTERNAL, date_joined__gte=last_month)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
.count()
)
# Forecast for external users
external_in_last_month = LicenseKey.get_external_user_count()
forecast_for_months = 12

View File

@@ -154,8 +154,15 @@ class LicenseKey:
@staticmethod
def base_user_qs() -> QuerySet:
"""Base query set for all users"""
return User.objects.all().exclude_anonymous().exclude(is_active=False)
"""Base query set for all users (excludes agents from license counting)"""
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK
return (
User.objects.all()
.exclude_anonymous()
.exclude(is_active=False)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
)
@staticmethod
def get_internal_user_count():

View File

@@ -141,8 +141,12 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
# 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
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK
base = (
base.exclude(type=UserTypes.SERVICE_ACCOUNT)
.exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
)
if self.filter_group:
base = base.filter(groups__in=[self.filter_group])

View File

@@ -130,8 +130,12 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
# 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
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK
base = (
base.exclude(type=UserTypes.SERVICE_ACCOUNT)
.exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
)
if self.filter_group:
base = base.filter(groups__in=[self.filter_group])

View File

@@ -6,6 +6,25 @@ from dramatiq.actor import actor
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 (
USER_ATTRIBUTE_AGENT_OWNER_PK,
Session,
User,
)
agents = User.objects.filter(
attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK,
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()

View File

@@ -0,0 +1,55 @@
"""Enterprise task tests"""
from django.test import TestCase
from authentik.core.models import (
USER_ATTRIBUTE_AGENT_OWNER_PK,
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.INTERNAL,
attributes={USER_ATTRIBUTE_AGENT_OWNER_PK: str(owner.pk)},
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 internal users"""
from authentik.enterprise.tasks import _deactivate_agent_users
internal = User.objects.create(
username=generate_id(),
type=UserTypes.INTERNAL,
is_active=True,
)
_deactivate_agent_users()
internal.refresh_from_db()
self.assertTrue(internal.is_active)

View File

@@ -31,7 +31,7 @@ from authentik.policies.types import PolicyRequest
# 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,
)

View File

@@ -202,9 +202,49 @@ class 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,
)
user = self.request.user
if not hasattr(user, "attributes"):
return None
owner_pk = user.attributes.get(USER_ATTRIBUTE_AGENT_OWNER_PK)
if not owner_pk:
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 = 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)

View File

@@ -50,6 +50,50 @@ class PolicyEvaluator(BaseEvaluator):
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 _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,
)
from authentik.policies.engine import PolicyEngine
user = request.user
app = request.obj
if not hasattr(user, "attributes"):
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 set_http_request(self, request: HttpRequest):
"""Update context based on http request"""

View File

@@ -5,7 +5,13 @@ from guardian.shortcuts import get_anonymous_user
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,
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 +141,75 @@ class TestEvaluator(TestCase):
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.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: str(self.owner.pk),
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: allowed_apps if allowed_apps is not None else [],
},
)
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.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: "999999",
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [str(self.app.pk)],
},
)
evaluator = self._evaluator_with(agent)
result = evaluator._context["has_access_to_application"]()
self.assertFalse(result)
class TestExpressionPolicyAPI(APITestCase):
"""Test expression policy's API"""

View File

@@ -5,7 +5,14 @@ from django.db import connections
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,
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 +216,91 @@ class TestPolicyEngine(TestCase):
engine.build()
self.assertLess(ctx.final_queries, 1000)
self.assertTrue(engine.result.passing)
def test_anonymous_user(self):
"""AnonymousUser (no attributes) 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"""
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.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: str(self.owner.pk),
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: allowed_apps if allowed_apps is not None else [],
},
)
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.INTERNAL,
attributes={
USER_ATTRIBUTE_AGENT_OWNER_PK: "999999",
USER_ATTRIBUTE_AGENT_ALLOWED_APPS: [str(self.app.pk)],
},
)
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)

View File

@@ -188,8 +188,12 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
# 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
from authentik.core.models import USER_ATTRIBUTE_AGENT_OWNER_PK
base = (
base.exclude(type=UserTypes.SERVICE_ACCOUNT)
.exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
.exclude(attributes__has_key=USER_ATTRIBUTE_AGENT_OWNER_PK)
)
# Filter users by their access to the backchannel application if an application is set

View File

@@ -5545,6 +5545,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 +6258,7 @@
"permission": {
"type": "string",
"enum": [
"add_agent_user",
"add_user",
"change_user",
"delete_user",
@@ -11220,6 +11222,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",

View File

@@ -41,6 +41,7 @@ import type {
PatchedBrandRequest,
PatchedGroupRequest,
PatchedTokenRequest,
PatchedUserAgentAllowedAppRequest,
PatchedUserRequest,
PolicyTestResult,
SessionUser,
@@ -53,6 +54,10 @@ import type {
UsedBy,
User,
UserAccountRequest,
UserAgentAllowedApps,
UserAgentAllowedAppsRequest,
UserAgentRequest,
UserAgentResponse,
UserConsent,
UserPasswordSetRequest,
UserPath,
@@ -91,6 +96,7 @@ import {
PatchedBrandRequestToJSON,
PatchedGroupRequestToJSON,
PatchedTokenRequestToJSON,
PatchedUserAgentAllowedAppRequestToJSON,
PatchedUserRequestToJSON,
PolicyTestResultFromJSON,
SessionUserFromJSON,
@@ -102,6 +108,10 @@ import {
TransactionApplicationResponseFromJSON,
UsedByFromJSON,
UserAccountRequestToJSON,
UserAgentAllowedAppsFromJSON,
UserAgentAllowedAppsRequestToJSON,
UserAgentRequestToJSON,
UserAgentResponseFromJSON,
UserConsentFromJSON,
UserFromJSON,
UserPasswordSetRequestToJSON,
@@ -358,6 +368,10 @@ export interface CoreTokensRetrieveRequest {
identifier: string;
}
export interface CoreTokensRotateCreateRequest {
identifier: string;
}
export interface CoreTokensSetKeyCreateRequest {
identifier: string;
tokenSetKeyRequest: TokenSetKeyRequest;
@@ -401,6 +415,20 @@ export interface CoreUserConsentUsedByListRequest {
id: number;
}
export interface CoreUsersAgentAllowedAppPartialUpdateRequest {
id: number;
patchedUserAgentAllowedAppRequest?: PatchedUserAgentAllowedAppRequest;
}
export interface CoreUsersAgentAllowedAppsUpdateRequest {
id: number;
userAgentAllowedAppsRequest: UserAgentAllowedAppsRequest;
}
export interface CoreUsersAgentCreateRequest {
userAgentRequest: UserAgentRequest;
}
export interface CoreUsersCreateRequest {
userRequest: UserRequest;
}
@@ -521,6 +549,45 @@ export interface CoreUsersUsedByListRequest {
*
*/
export class CoreApi extends runtime.BaseAPI {
/**
* Creates request options for coreAgentSessionCreate without sending the request
*/
async coreAgentSessionCreateRequestOpts(): Promise<runtime.RequestOpts> {
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<runtime.ApiResponse<void>> {
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<void> {
await this.coreAgentSessionCreateRaw(initOverrides);
}
/**
* Creates request options for coreApplicationEntitlementsCreate without sending the request
*/
@@ -3575,6 +3642,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<runtime.RequestOpts> {
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<runtime.ApiResponse<TokenView>> {
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<TokenView> {
const response = await this.coreTokensRotateCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* Creates request options for coreTokensSetKeyCreate without sending the request
*/
@@ -4182,6 +4313,229 @@ export class CoreApi extends runtime.BaseAPI {
return await response.value();
}
/**
* Creates request options for coreUsersAgentAllowedAppPartialUpdate without sending the request
*/
async coreUsersAgentAllowedAppPartialUpdateRequestOpts(
requestParameters: CoreUsersAgentAllowedAppPartialUpdateRequest,
): Promise<runtime.RequestOpts> {
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<runtime.ApiResponse<UserAgentAllowedApps>> {
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<UserAgentAllowedApps | null | undefined> {
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
*/
async coreUsersAgentAllowedAppsUpdateRequestOpts(
requestParameters: CoreUsersAgentAllowedAppsUpdateRequest,
): Promise<runtime.RequestOpts> {
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<runtime.ApiResponse<UserAgentAllowedApps>> {
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<UserAgentAllowedApps> {
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<runtime.RequestOpts> {
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. Agent users are internal users with an owner attribute that grants scoped application access on behalf of the owner.
*/
async coreUsersAgentCreateRaw(
requestParameters: CoreUsersAgentCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<UserAgentResponse>> {
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. Agent users are internal users with an owner attribute that grants scoped application access on behalf of the owner.
*/
async coreUsersAgentCreate(
requestParameters: CoreUsersAgentCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<UserAgentResponse> {
const response = await this.coreUsersAgentCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* Creates request options for coreUsersCreate without sending the request
*/

View File

@@ -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"]),
};
}

View File

@@ -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;
}

View File

@@ -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 replace an agent's allowed applications
* @export
* @interface UserAgentAllowedApps
*/
export interface UserAgentAllowedApps {
/**
*
* @type {Array<string>}
* @memberof UserAgentAllowedApps
*/
allowedApps: Array<string>;
}
/**
* 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"],
};
}

View File

@@ -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 replace an agent's allowed applications
* @export
* @interface UserAgentAllowedAppsRequest
*/
export interface UserAgentAllowedAppsRequest {
/**
*
* @type {Array<string>}
* @memberof UserAgentAllowedAppsRequest
*/
allowedApps: Array<string>;
}
/**
* 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"],
};
}

View File

@@ -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"],
};
}

View File

@@ -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"],
};
}

View File

@@ -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,11 @@ export * from "./UsedByActionEnum";
export * from "./User";
export * from "./UserAccountRequest";
export * from "./UserAccountSerializerForRoleRequest";
export * from "./UserAgentAllowedAppActionEnum";
export * from "./UserAgentAllowedApps";
export * from "./UserAgentAllowedAppsRequest";
export * from "./UserAgentRequest";
export * from "./UserAgentResponse";
export * from "./UserAttributeEnum";
export * from "./UserConsent";
export * from "./UserCreationModeEnum";

View File

@@ -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
@@ -3896,6 +3911,35 @@ 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
'404':
description: Token not found
'400':
$ref: '#/components/responses/ValidationErrorResponse'
/core/tokens/{identifier}/set_key/:
post:
operationId: core_tokens_set_key_create
@@ -4406,6 +4450,75 @@ 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
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 +4663,34 @@ 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.
Agent users are internal users with an owner attribute that grants scoped
application access on behalf of the owner.
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
@@ -50637,6 +50778,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
@@ -56816,6 +56966,61 @@ components:
type: integer
required:
- pk
UserAgentAllowedAppActionEnum:
enum:
- add
- remove
type: string
UserAgentAllowedApps:
type: object
description: Payload to replace an agent's allowed applications
properties:
allowed_apps:
type: array
items:
type: string
format: uuid
required:
- allowed_apps
UserAgentAllowedAppsRequest:
type: object
description: Payload to replace 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

View File

@@ -0,0 +1,74 @@
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`<ak-form-element-horizontal label=${msg("Application")} required name="app">
<ak-search-select
placeholder=${msg("Select an application...")}
.fetchObjects=${async (query?: string): Promise<Application[]> => {
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")}`;
}}
>
</ak-search-select>
</ak-form-element-horizontal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-agent-add-application-form": AgentAddApplicationForm;
}
}

View File

@@ -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<UserAgentRequest> {
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<UserAgentResponse> {
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`<ak-text-input
name="name"
label=${msg("Username")}
placeholder=${msg("Type a username for the agent...")}
value=""
input-hint="code"
required
maxlength=${150}
autofocus
help=${msg(
"The agent's primary identifier used for authentication. 150 characters or fewer.",
)}
></ak-text-input>`;
}
protected renderResponseForm(): SlottedTemplateResult {
return html`<p>
${msg(
"Use the username and token below to authenticate. The token expires in 24 hours and must be rotated before expiry.",
)}
</p>
<form class="pf-c-form pf-m-horizontal">
<ak-text-input
name="name"
label=${msg("Username")}
autocomplete="off"
value=${ifDefined(this.result?.username)}
input-hint="code"
readonly
></ak-text-input>
<ak-hidden-text-input
label=${msg("Token")}
value="${this.result?.token ?? ""}"
input-hint="code"
readonly
.help=${msg(
"Valid for 24 hours. The agent must rotate the token before it expires. If the rotation window is missed, the owner must issue a new token.",
)}
>
</ak-hidden-text-input>
</form>`;
}
protected override renderFormWrapper(): SlottedTemplateResult {
if (this.result) {
return this.renderResponseForm();
}
return super.renderFormWrapper();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-agent-form": AgentForm;
}
}

View File

@@ -1,8 +1,10 @@
import "#admin/users/AgentAddApplicationForm";
import "#elements/AppIcon";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { renderModal } from "#elements/dialogs";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
@@ -15,6 +17,8 @@ import { msg } from "@lit/localize";
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-user-application-table")
export class UserApplicationTable extends Table<Application> {
@property({ attribute: false })
@@ -22,6 +26,10 @@ export class UserApplicationTable extends Table<Application> {
static styles: CSSResult[] = [...super.styles, applicationListStyle];
private get isAgent(): boolean {
return !!this.user?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK];
}
async apiEndpoint(): Promise<PaginatedResponse<Application>> {
return new CoreApi(DEFAULT_CONFIG).coreApplicationsList({
...(await this.defaultEndpointConfig()),
@@ -38,6 +46,35 @@ export class UserApplicationTable extends Table<Application> {
[msg("Actions"), null, msg("Row Actions")],
];
private async removeApplication(app: Application): Promise<void> {
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 = () => {
renderModal(
html`<ak-agent-add-application-form
.agent=${this.user}
></ak-agent-add-application-form>`,
).then(() => {
this.fetch();
});
};
protected override renderToolbar(): SlottedTemplateResult {
if (!this.isAgent) {
return super.renderToolbar();
}
return html`<button class="pf-c-button pf-m-primary" @click=${this.openAddApplicationModal}>
${msg("Add Application")}
</button>
${super.renderToolbar()}`;
}
row(item: Application): SlottedTemplateResult[] {
return [
html`<ak-app-icon name=${item.name} icon=${ifPresent(item.metaIconUrl)}></ak-app-icon>`,
@@ -71,6 +108,16 @@ export class UserApplicationTable extends Table<Application> {
</pf-tooltip>
</a>`
: nothing}
${this.isAgent
? html`<button
class="pf-c-button pf-m-plain"
@click=${() => this.removeApplication(item)}
>
<pf-tooltip position="top" content=${msg("Remove")}>
<i class="fas fa-trash" aria-hidden="true"></i>
</pf-tooltip>
</button>`
: nothing}
</div>`,
];
}

View File

@@ -22,6 +22,8 @@ import { css, CSSResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const USER_ATTRIBUTE_AGENT_OWNER_PK = "goauthentik.io/agent/owner-pk";
const UserTypeOptions: readonly RadioOption<UserTypeEnum>[] = [
{
label: msg("Internal"),
@@ -86,9 +88,19 @@ export class UserForm extends ModelForm<User, number> {
});
}
private get isAgent(): boolean {
return !!this.instance?.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK];
}
protected override assignInstance(instance: User): void {
super.assignInstance(instance);
if (this.isAgent) {
this.verboseName = msg("Agent User");
this.verboseNamePlural = msg("Agent Users");
return;
}
const { verboseName, verboseNamePlural } = match(instance.type)
.with(UserTypeEnum.Internal, () => ({
verboseName: msg("Internal User"),
@@ -203,27 +215,44 @@ export class UserForm extends ModelForm<User, number> {
${this.userType
? null
: html`<ak-radio-input
label=${msg("User type")}
required
name="type"
.value=${this.instance?.type}
.options=${[
...UserTypeOptions,
...(this.instance
? [
{
label: msg("Internal Service account"),
value: UserTypeEnum.InternalServiceAccount,
disabled: true,
description: html`${msg(
"Managed by authentik and cannot be assigned manually.",
)}`,
},
]
: []),
] satisfies RadioOption<UserTypeEnum>[]}
></ak-radio-input>`}
: this.isAgent
? html`<ak-radio-input
label=${msg("User type")}
required
name="type"
.value=${UserTypeEnum.Internal}
.options=${[
{
label: msg("Agent"),
value: UserTypeEnum.Internal,
disabled: true,
description: html`${msg(
"Agent users are managed by their owner and cannot change type.",
)}`,
},
] satisfies RadioOption<UserTypeEnum>[]}
></ak-radio-input>`
: html`<ak-radio-input
label=${msg("User type")}
required
name="type"
.value=${this.instance?.type}
.options=${[
...UserTypeOptions,
...(this.instance
? [
{
label: msg("Internal Service account"),
value: UserTypeEnum.InternalServiceAccount,
disabled: true,
description: html`${msg(
"Managed by authentik and cannot be assigned manually.",
)}`,
},
]
: []),
] satisfies RadioOption<UserTypeEnum>[]}
></ak-radio-input>`}
<ak-text-input
name="email"
label=${msg("Email Address")}

View File

@@ -15,7 +15,7 @@ import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { userTypeToLabel } from "#common/labels";
import { userDisplayLabel } from "#common/labels";
import { DefaultUIConfig } from "#common/ui/config";
import { formatUserDisplayName } from "#common/users";
@@ -258,7 +258,7 @@ export class UserListPage extends WithBrandConfig(
</a>`,
html`<ak-status-label ?good=${item.isActive}></ak-status-label>`,
Timestamp(item.lastLogin),
html`${userTypeToLabel(item.type)}`,
html`${userDisplayLabel(item)}`,
html`<div class="ak-c-table__actions">
${IconEditButton(UserForm, item.pk, displayName)}
${showImpersonation

View File

@@ -28,7 +28,7 @@ import "./UserDevicesTable.js";
import "#elements/ak-mdx/ak-mdx";
import { DEFAULT_CONFIG } from "#common/api/config";
import { userTypeToLabel } from "#common/labels";
import { userDisplayLabel } from "#common/labels";
import { formatUserDisplayName } from "#common/users";
import { AKElement } from "#elements/Base";
@@ -118,7 +118,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
[msg("Last login"), Timestamp(user.lastLogin)],
[msg("Last password change"), Timestamp(user.passwordChangeDate)],
[msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>`],
[msg("Type"), userTypeToLabel(user.type)],
[msg("Type"), userDisplayLabel(user)],
[msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>`],
[msg("Actions"), this.renderActionButtons(user)],
[msg("Recovery"), this.renderRecoveryButtons(user)],

View File

@@ -1,3 +1,4 @@
import "#admin/users/AgentForm";
import "#admin/users/ServiceAccountForm";
import "#admin/users/UserForm";
import "#components/ak-hidden-text-input";
@@ -13,7 +14,12 @@ import { WizardPage } from "#elements/wizard/WizardPage";
import { UserForm } from "#admin/users/UserForm";
import { TypeCreate, UserServiceAccountResponse, UserTypeEnum } from "@goauthentik/api";
import {
TypeCreate,
UserAgentResponse,
UserServiceAccountResponse,
UserTypeEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html } from "lit";
@@ -22,6 +28,10 @@ import { customElement, property, state } from "lit/decorators.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
const AGENT_MODEL_NAME = "agent";
const AGENT_FORM_SLOT = `type-ak-user-agent-form-${AGENT_MODEL_NAME}` as const;
const AGENT_RESULT_SLOT = `${AGENT_FORM_SLOT}-result` as const;
const SERVICE_ACCOUNT_FORM_SLOT =
`type-ak-user-service-account-form-${UserTypeEnum.ServiceAccount}` as const;
const SERVICE_ACCOUNT_RESULT_SLOT = `${SERVICE_ACCOUNT_FORM_SLOT}-result` as const;
@@ -41,6 +51,13 @@ const DEFAULT_USER_TYPES: TypeCreate[] = [
"External consultants or B2C customers without access to enterprise features.",
),
},
{
component: "ak-user-agent-form",
modelName: AGENT_MODEL_NAME,
name: msg("Agent"),
description: msg("Machine user owned by an internal user, with scoped application access."),
requiresEnterprise: true,
},
{
component: "ak-user-service-account-form",
modelName: UserTypeEnum.ServiceAccount,
@@ -50,6 +67,7 @@ const DEFAULT_USER_TYPES: TypeCreate[] = [
];
export interface UserWizardState {
[AGENT_FORM_SLOT]?: UserAgentResponse;
[SERVICE_ACCOUNT_FORM_SLOT]?: UserServiceAccountResponse;
}
@@ -110,6 +128,63 @@ export class ServiceAccountResultPage extends WizardPage<UserWizardState> {
}
}
@customElement("ak-user-agent-result-page")
export class AgentResultPage extends WizardPage<UserWizardState> {
public static styles: CSSResult[] = [PFForm, PFFormControl];
public override headline = msg("Review Credentials");
@state()
protected result: UserAgentResponse | null = null;
public override activeCallback = async (): Promise<void> => {
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<boolean> => true;
protected override render(): SlottedTemplateResult {
if (!this.result) {
return null;
}
const { username, token } = this.result;
return html`<h3 class="pf-c-wizard__main-title">${msg("Review Credentials")}</h3>
<h4 class="pf-c-title pf-m-md">
${msg(
"Use the username and token below to authenticate. The token expires in 24 hours and must be rotated before expiry.",
)}
</h4>
<form class="pf-c-form pf-m-horizontal">
<ak-text-input
label=${msg("Username")}
value=${username}
input-hint="code"
readonly
></ak-text-input>
<ak-hidden-text-input
label=${msg("Token")}
value="${token}"
input-hint="code"
readonly
.help=${msg(
"Valid for 24 hours. The agent must rotate the token before it expires.",
)}
></ak-hidden-text-input>
</form>`;
}
}
@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 === AGENT_MODEL_NAME) {
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 === AGENT_MODEL_NAME) {
return [
super.renderWizardStep(type),
html`<ak-user-agent-result-page
slot=${AGENT_RESULT_SLOT}
></ak-user-agent-result-page>`,
];
}
if (type.modelName === UserTypeEnum.ServiceAccount) {
return [
super.renderWizardStep(type),
@@ -155,7 +238,7 @@ export class AKUserWizard extends CreateWizard {
}
protected override assembleFormProps(type: TypeCreate): LitPropertyRecord<UserForm | object> {
if (type.modelName === UserTypeEnum.ServiceAccount) {
if (type.modelName === AGENT_MODEL_NAME || type.modelName === UserTypeEnum.ServiceAccount) {
return {};
}
@@ -171,6 +254,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;
}
}

View File

@@ -5,11 +5,14 @@ import {
EventActions,
IntentEnum,
SeverityEnum,
User,
UserTypeEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
const USER_ATTRIBUTE_AGENT_OWNER_PK = "goauthentik.io/agent/owner-pk";
/* Various tables in the API for which we need to supply labels */
export const intentEnumToLabel = new Map<IntentEnum, string>([
@@ -122,3 +125,6 @@ const _userTypeToLabel = new Map<UserTypeEnum | undefined, string>([
export const userTypeToLabel = (type?: UserTypeEnum): string =>
_userTypeToLabel.get(type) ?? type ?? "";
export const userDisplayLabel = (user: User): string =>
user.attributes?.[USER_ATTRIBUTE_AGENT_OWNER_PK] ? msg("Agent") : userTypeToLabel(user.type);