core: Actors (#24353)

* I think I'm happy with this structure

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fixup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add api

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing engine mode

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add ui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* pull in the requests machinery

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fixup and gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-wire things up

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add more web stuff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix unrelated test issue

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix mixin

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* plumb in flows

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update ui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* initial web

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a couple things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix api tests & schema

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better pick request flow

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix perms

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* change method

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix perms

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix web lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* explicit perms

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix migrations

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* concrete m2m

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix perms

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add some default config

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix permission check -> requesting user checks against rule bindings' policies

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix unrelated

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix api client

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix styling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix more

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add banner

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* attributes mixin prep for object attrs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix unrelated test failure

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ensure no self-approval

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* configurable expiry

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add meta info to requestable target

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add more events

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only unique apps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* initial app card impl

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add entitlement modal

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* respect filters

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-migrate

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve ux

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix child bindings?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better ux for rule bindings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix type check

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* initial dcr

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-migrate

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* handle error

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* init

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rename

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rename

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* self-service

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* wip

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* move to user view

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* always expire self

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* t

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* actor

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* effective user early

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* extract agents

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* optimize

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Apply suggestion from @rissson

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Jens L.
2026-08-03 20:46:20 +01:00
committed by GitHub
parent 3dc9192bb1
commit ea2a25785c
8 changed files with 1312 additions and 6 deletions

View File

@@ -0,0 +1,66 @@
# Generated by Django 5.2.16 on 2026-07-31 14:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0062_userswitchingsession_and_more"),
]
operations = [
migrations.CreateModel(
name="Actor",
fields=[
(
"user_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to=settings.AUTH_USER_MODEL,
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"policy_behavior",
models.TextField(
choices=[
("mirror", "Mirror policy engine"),
("copy", "Copy policy bindings"),
("none", "Don't inherit any policy bindings"),
]
),
),
(
"parent",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="actors",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Actor",
"verbose_name_plural": "Actors",
"abstract": False,
"indexes": [
models.Index(fields=["expires"], name="authentik_c_expires_ce0be4_idx"),
models.Index(fields=["expiring"], name="authentik_c_expirin_fbf26f_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_b80614_idx"
),
],
},
bases=("authentik_core.user", models.Model),
),
]

View File

@@ -1591,3 +1591,77 @@ class ObjectAttribute(SerializerModel, ManagedModel, CreatedUpdatedModel):
verbose_name = _("Object Attribute")
verbose_name_plural = _("Object Attributes")
unique_together = (("object_type", "key", "enabled"),)
class ActorPolicyInheritance(models.TextChoices):
MIRROR = "mirror", _("Mirror policy engine")
COPY = "copy", _("Copy policy bindings")
NONE = "none", _("Don't inherit any policy bindings")
class Actor(ExpiringModel, User):
"""Generic actor which can either perform tasks by itself
or on behalf of a parent user."""
parent = models.ForeignKey(
User, on_delete=models.SET_DEFAULT, default=None, null=True, related_name="actors"
)
policy_behavior = models.TextField(choices=ActorPolicyInheritance)
class Meta(ExpiringModel.Meta):
verbose_name = _("Actor")
verbose_name_plural = _("Actors")
def save(self, *args, **kwargs):
# policy_behavior determines how the actor derives access and may only be chosen at
# creation time; changing it afterwards is rejected.
if (
not self._state.adding
and hasattr(self, "_original_policy_behavior")
and self._original_policy_behavior != self.policy_behavior
):
raise ValidationError(
{"policy_behavior": _("Policy behavior cannot be changed after creation.")}
)
super().save(*args, **kwargs)
self._original_policy_behavior = self.policy_behavior
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
instance._original_policy_behavior = instance.policy_behavior
return instance
def copy_parent_policy_bindings(self):
"""Snapshot the parent's directly-assigned policy bindings onto this actor.
Unlike MIRROR (which defers to the parent live), COPY takes an independent snapshot at
creation: the actor holds its own PolicyBindings, so later changes to the parent don't
affect it. Only bindings that reference the parent as a *user* are copied -- access the
parent derives from group membership is not (use MIRROR, or add the actor to groups).
"""
from authentik.policies.models import PolicyBinding
if not self.parent_id:
return
for binding in PolicyBinding.objects.filter(user_id=self.parent_id):
binding.pk = None
binding.policy_binding_uuid = uuid4()
binding._state.adding = True
binding.user = self
binding.save()
@staticmethod
def actor_for(user: User | None, policy_behavior: ActorPolicyInheritance, **kwargs):
prefix = f"{user.username}" if user else "global"
actor = Actor.objects.create(
username=f"{prefix}-{generate_id()}",
parent=user,
policy_behavior=policy_behavior,
type=UserTypes.SERVICE_ACCOUNT,
**kwargs,
)
actor.set_unusable_password()
actor.save()
return actor

View File

@@ -23,7 +23,7 @@ from guardian.conf import settings
from guardian.shortcuts import get_anonymous_user
from authentik.blueprints.v1.common import YAMLTag
from authentik.core.models import User
from authentik.core.models import User, UserTypes
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
from authentik.policies.types import PolicyRequest
@@ -93,6 +93,12 @@ def get_user(user: User | AnonymousUser) -> dict[str, Any]:
}
if user.username == settings.ANONYMOUS_USER_NAME:
user_data["is_anonymous"] = True
# Actions performed by an actor are recorded on behalf of its parent, so the
# audit log always ties the activity back to a responsible human.
if getattr(user, "type", None) == UserTypes.SERVICE_ACCOUNT and hasattr(user, "actor"):
user_data["is_agent"] = True
# FIXME: This will need to be adjusted for OAuth OBO
user_data["on_behalf_of"] = get_user(user.actor.parent)
return user_data

View File

@@ -13,7 +13,7 @@ from django.utils.timezone import now
from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import Group, User
from authentik.core.models import Actor, ActorPolicyInheritance, Group, User, UserTypes
from authentik.lib.utils.reflection import class_to_path
from authentik.policies.apps import HIST_POLICIES_ENGINE_TOTAL_TIME, HIST_POLICIES_EXECUTION_TIME
from authentik.policies.exceptions import PolicyEngineException
@@ -23,6 +23,36 @@ from authentik.policies.types import PolicyRequest, PolicyResult
CURRENT_PROCESS = current_process()
# Actors are always service accounts, so a cheap type check keeps the hot policy path free of an
# extra query for ordinary (human) users.
_ACTOR_USER_TYPES = frozenset({UserTypes.SERVICE_ACCOUNT, UserTypes.INTERNAL_SERVICE_ACCOUNT})
def _get_mirror_parent(user: User) -> User | None:
"""Return the parent a MIRROR actor mirrors its policy from, or None.
An actor with ``policy_behavior == MIRROR`` is evaluated as its parent: it passes a policy
exactly when the parent does. Detection resolves the multi-table-inheritance child, memoized
on the user instance.
"""
if getattr(user, "type", None) not in _ACTOR_USER_TYPES:
return None
if "_actor" not in user.__dict__:
user.__dict__["_actor"] = Actor.objects.filter(pk=user.pk).first()
actor: Actor | None = user.__dict__["_actor"]
if actor and actor.policy_behavior == ActorPolicyInheritance.MIRROR and actor.parent_id:
return actor.parent
return None
def effective_policy_user(user: User) -> User:
"""Follow MIRROR actors up to the identity whose policy result they mirror."""
seen = {user.pk}
while (parent := _get_mirror_parent(user)) is not None and parent.pk not in seen:
seen.add(parent.pk)
user = parent
return user
class PolicyProcessInfo:
"""Dataclass to hold all information and communication channels to a process"""
@@ -185,6 +215,7 @@ class PolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
raise PolicyEngineException(f"{pbm} is not instance of PolicyBindingModel")
if not user:
raise PolicyEngineException("User must be set")
user = effective_policy_user(user)
self.__pbm = pbm
self.request = PolicyRequest(user)
self.request.obj = pbm
@@ -286,6 +317,8 @@ class FilterPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
self._init_defaults(pbm)
self.__pbm = pbm
self.__users = users
self.__original_users = users
self.__mirror_of: dict = {}
self.__http_request = request
self.__result: QuerySet[User] | None = None
@@ -293,6 +326,24 @@ class FilterPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
"""Get enabled bindings for the bound PBM"""
return self._bindings_for(self.__pbm)
def _substitute_mirror_actors(self):
"""Swap MIRROR actors in the user set for the identity they mirror, so they are
evaluated in the same pass as everyone else (`_finalize` maps the verdict back). Only
service accounts can be actors, so the scan is a cheap, targeted query, and parents that
are shared across actors are evaluated once.
"""
for actor in self.__original_users.filter(type__in=_ACTOR_USER_TYPES):
effective = effective_policy_user(actor)
if effective.pk != actor.pk:
self.__mirror_of[actor.pk] = effective
if not self.__mirror_of:
return
parent_pks = {parent.pk for parent in self.__mirror_of.values()}
self.__users = User.objects.filter(
Q(pk__in=self.__original_users.exclude(pk__in=self.__mirror_of.keys()).values("pk"))
| Q(pk__in=parent_pks)
)
def build(self) -> FilterPolicyEngine:
"""Evaluate bindings against the user queryset"""
with (
@@ -305,13 +356,14 @@ class FilterPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
obj_pk=str(self.__pbm.pk),
).time(),
):
self._substitute_mirror_actors()
bindings = list(self.bindings())
for binding in bindings:
self._check_policy_type(binding)
if not bindings:
self.__result = self.__users if self.empty_result else self.__users.none()
return self
return self._finalize()
dynamic_bindings = [binding for binding in bindings if binding.policy_id is not None]
static_bindings = [
@@ -326,7 +378,7 @@ class FilterPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
self.__result = self.__users if self.empty_result else self.__users.none()
else:
self.__result = self._filter_static(self.__users, static_bindings, self.mode)
return self
return self._finalize()
# Slow path: real Policy objects can't be translated to SQL and need
# per-user evaluation. Pre-compute the static verdict ONCE via SQL (reused
@@ -362,7 +414,25 @@ class FilterPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
if self._combine_results(self.mode, self.empty_result, all_results).passing:
passing_pks.append(user.pk)
self.__result = self.__users.filter(pk__in=passing_pks)
return self._finalize()
def _finalize(self) -> FilterPolicyEngine:
"""Map the effective-user verdicts back onto the original user set.
`build()` evaluated the substituted set (parents standing in for their MIRROR actors), so
a substituted actor passes iff the identity it mirrors passes. No-op when nothing was
substituted -- `self.__result` already refers to the original users.
"""
if not self.__mirror_of:
return self
passing = set(self.__result.values_list("pk", flat=True))
final_pks = {
pk
for pk in self.__original_users.values_list("pk", flat=True)
if (self.__mirror_of[pk].pk if pk in self.__mirror_of else pk) in passing
}
self.__result = self.__original_users.filter(pk__in=final_pks)
return self
def _prefetch_cache(
self, candidates: list[User], dynamic_bindings: list[PolicyBinding]
@@ -441,7 +511,7 @@ class ListPolicyEngine[T: PolicyBindingModel](_PolicyEngineBase):
self.empty_result = True
self.use_cache = True
self.__objs = objs
self.__user = user
self.__user = effective_policy_user(user)
self.__http_request = request
self.__result: QuerySet[T] | None = None

View File

@@ -9,7 +9,7 @@ from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.utils.timezone import now
from authentik.core.models import Group, User
from authentik.core.models import Actor, ActorPolicyInheritance, 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
@@ -360,3 +360,42 @@ class TestFilterPolicyEngine(TestCase):
self.assertEqual(second, {self.user_a.pk, self.user_b.pk, self.user_c.pk})
mock_get_many.assert_called_once()
def _mirror_actor(self, parent: User) -> Actor:
uid = generate_id()
return Actor.objects.create(
username=uid,
name=uid,
email=f"{uid}@goauthentik.io",
parent=parent,
policy_behavior=ActorPolicyInheritance.MIRROR,
type=UserTypes.SERVICE_ACCOUNT,
)
def test_mirror_actor_evaluated_as_parent(self):
"""A MIRROR actor passes iff the identity it mirrors passes, folded into the same
evaluation -- no per-actor PolicyEngine is spawned on the static path."""
actor_a = self._mirror_actor(self.user_a) # user_a is in group_a
actor_c = self._mirror_actor(self.user_c) # user_c is in no group
users = User.objects.filter(pk__in=[actor_a.pk, actor_c.pk, self.user_a.pk, self.user_c.pk])
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, group=self.group_a, order=0)
with patch("authentik.policies.engine.PolicyEngine") as mock_engine:
result = set(FilterPolicyEngine(pbm, users).build().result.values_list("pk", flat=True))
# actor_a mirrors user_a (in group_a) -> passes; actor_c mirrors user_c -> denied.
self.assertEqual(result, {self.user_a.pk, actor_a.pk})
self.assertEqual(mock_engine.call_count, 0)
def test_mirror_actor_without_parent_in_set(self):
"""The mirrored parent need not itself be in the queryset."""
actor_a = self._mirror_actor(self.user_a)
users = User.objects.filter(pk__in=[actor_a.pk, self.user_c.pk])
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, group=self.group_a, order=0)
engine = FilterPolicyEngine(pbm, users)
result = set(engine.build().result.values_list("pk", flat=True))
# Only the actor passes (via user_a's membership); user_a itself is not in the result set.
self.assertEqual(result, {actor_a.pk})

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ export const ModelEnum = {
AuthentikCoreApplicationentitlement: "authentik_core.applicationentitlement",
AuthentikCoreToken: "authentik_core.token",
AuthentikCoreObjectattribute: "authentik_core.objectattribute",
AuthentikCoreActor: "authentik_core.actor",
AuthentikCryptoCertificatekeypair: "authentik_crypto.certificatekeypair",
AuthentikEndpointsDeviceuserbinding: "authentik_endpoints.deviceuserbinding",
AuthentikEndpointsDeviceaccessgroup: "authentik_endpoints.deviceaccessgroup",

View File

@@ -45013,6 +45013,7 @@ components:
- authentik_core.applicationentitlement
- authentik_core.token
- authentik_core.objectattribute
- authentik_core.actor
- authentik_crypto.certificatekeypair
- authentik_endpoints.deviceuserbinding
- authentik_endpoints.deviceaccessgroup