From 680db9bae63aa27c84ef5525d6660106bcd482c3 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Wed, 25 Jun 2025 18:22:51 +0200 Subject: [PATCH] events: use pending_user as user when possible (#15238) * unrelated: dont show nested for user Signed-off-by: Jens Langhammer * unrelated: fix error when no extents in. map Signed-off-by: Jens Langhammer * events: use pending_user when possible Signed-off-by: Jens Langhammer * fix for identification stage "fake" user Signed-off-by: Jens Langhammer * better username rendering Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/core/api/users.py | 2 +- authentik/events/models.py | 25 ++++- authentik/events/tests/test_event.py | 95 ++++++++++++++++++- authentik/events/utils.py | 8 +- .../admin-overview/cards/RecentEventsCard.ts | 4 +- web/src/admin/events/EventListPage.ts | 4 +- web/src/admin/events/EventMap.ts | 4 + web/src/admin/events/EventViewPage.ts | 4 +- web/src/admin/events/utils.ts | 53 +++++++---- web/src/common/events.ts | 3 +- web/src/components/events/ObjectChangelog.ts | 4 +- web/src/components/events/UserEvents.ts | 4 +- 12 files changed, 167 insertions(+), 43 deletions(-) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 1933be055f..a67745ff08 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -407,7 +407,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): StrField(User, "path"), BoolField(User, "is_active", nullable=True), ChoiceSearchField(User, "type"), - JSONSearchField(User, "attributes"), + JSONSearchField(User, "attributes", suggest_nested=False), ] def get_queryset(self): diff --git a/authentik/events/models.py b/authentik/events/models.py index 5d4646c561..e5721105c0 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -193,17 +193,32 @@ class Event(SerializerModel, ExpiringModel): brand: Brand = request.brand self.brand = sanitize_dict(model_to_dict(brand)) if hasattr(request, "user"): - original_user = None - if hasattr(request, "session"): - original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None) - self.user = get_user(request.user, original_user) + self.user = get_user(request.user) if user: self.user = get_user(user) - # Check if we're currently impersonating, and add that user if hasattr(request, "session"): + from authentik.flows.views.executor import SESSION_KEY_PLAN + + # Check if we're currently impersonating, and add that user if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) + # Special case for events that happen during a flow, the user might not be authenticated + # yet but is a pending user instead + if SESSION_KEY_PLAN in request.session: + from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan + + plan: FlowPlan = request.session[SESSION_KEY_PLAN] + pending_user = plan.context.get(PLAN_CONTEXT_PENDING_USER, None) + # Only save `authenticated_as` if there's a different pending user in the flow + # than the user that is authenticated + if pending_user and ( + (pending_user.pk and pending_user.pk != self.user.get("pk")) + or (not pending_user.pk) + ): + orig_user = self.user.copy() + + self.user = {"authenticated_as": orig_user, **get_user(pending_user)} # User 255.255.255.255 as fallback if IP cannot be determined self.client_ip = ClientIPMiddleware.get_client_ip(request) # Enrich event data diff --git a/authentik/events/tests/test_event.py b/authentik/events/tests/test_event.py index b40aad7be7..65244f9dfe 100644 --- a/authentik/events/tests/test_event.py +++ b/authentik/events/tests/test_event.py @@ -8,9 +8,11 @@ from django.views.debug import SafeExceptionReporterFilter from guardian.shortcuts import get_anonymous_user from authentik.brands.models import Brand -from authentik.core.models import Group +from authentik.core.models import Group, User +from authentik.core.tests.utils import create_test_user from authentik.events.models import Event -from authentik.flows.views.executor import QS_QUERY +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN from authentik.lib.generators import generate_id from authentik.policies.dummy.models import DummyPolicy @@ -116,3 +118,92 @@ class TestEvents(TestCase): "pk": brand.pk.hex, }, ) + + def test_from_http_flow_pending_user(self): + """Test request from flow request with a pending user""" + user = create_test_user() + + session = self.client.session + plan = FlowPlan(generate_id()) + plan.context[PLAN_CONTEXT_PENDING_USER] = user + session[SESSION_KEY_PLAN] = plan + session.save() + + request = self.factory.get("/") + request.session = session + request.user = user + + event = Event.new("unittest").from_http(request) + self.assertEqual( + event.user, + { + "email": user.email, + "pk": user.pk, + "username": user.username, + }, + ) + + def test_from_http_flow_pending_user_anon(self): + """Test request from flow request with a pending user""" + user = create_test_user() + anon = get_anonymous_user() + + session = self.client.session + plan = FlowPlan(generate_id()) + plan.context[PLAN_CONTEXT_PENDING_USER] = user + session[SESSION_KEY_PLAN] = plan + session.save() + + request = self.factory.get("/") + request.session = session + request.user = anon + + event = Event.new("unittest").from_http(request) + self.assertEqual( + event.user, + { + "authenticated_as": { + "pk": anon.pk, + "is_anonymous": True, + "username": "AnonymousUser", + "email": "", + }, + "email": user.email, + "pk": user.pk, + "username": user.username, + }, + ) + + def test_from_http_flow_pending_user_fake(self): + """Test request from flow request with a pending user""" + user = User( + username=generate_id(), + email=generate_id(), + ) + anon = get_anonymous_user() + + session = self.client.session + plan = FlowPlan(generate_id()) + plan.context[PLAN_CONTEXT_PENDING_USER] = user + session[SESSION_KEY_PLAN] = plan + session.save() + + request = self.factory.get("/") + request.session = session + request.user = anon + + event = Event.new("unittest").from_http(request) + self.assertEqual( + event.user, + { + "authenticated_as": { + "pk": anon.pk, + "is_anonymous": True, + "username": "AnonymousUser", + "email": "", + }, + "email": user.email, + "pk": user.pk, + "username": user.username, + }, + ) diff --git a/authentik/events/utils.py b/authentik/events/utils.py index ab1778f446..d55d0ec927 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -74,8 +74,8 @@ def model_to_dict(model: Model) -> dict[str, Any]: } -def get_user(user: User | AnonymousUser, original_user: User | None = None) -> dict[str, Any]: - """Convert user object to dictionary, optionally including the original user""" +def get_user(user: User | AnonymousUser) -> dict[str, Any]: + """Convert user object to dictionary""" if isinstance(user, AnonymousUser): try: user = get_anonymous_user() @@ -88,10 +88,6 @@ def get_user(user: User | AnonymousUser, original_user: User | None = None) -> d } if user.username == settings.ANONYMOUS_USER_NAME: user_data["is_anonymous"] = True - if original_user: - original_data = get_user(original_user) - original_data["on_behalf_of"] = user_data - return original_data return user_data diff --git a/web/src/admin/admin-overview/cards/RecentEventsCard.ts b/web/src/admin/admin-overview/cards/RecentEventsCard.ts index c66d89dec6..bd2abbea45 100644 --- a/web/src/admin/admin-overview/cards/RecentEventsCard.ts +++ b/web/src/admin/admin-overview/cards/RecentEventsCard.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventGeo, renderEventUser } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -73,7 +73,7 @@ export class RecentEventsCard extends Table { return [ html` ${item.app}`, - EventUser(item), + renderEventUser(item), html`
${formatElapsedTime(item.created)}
${item.created.toLocaleString()}`, html`
${item.clientIp || msg("-")}
diff --git a/web/src/admin/events/EventListPage.ts b/web/src/admin/events/EventListPage.ts index 30a556f56e..6ddd598aa7 100644 --- a/web/src/admin/events/EventListPage.ts +++ b/web/src/admin/events/EventListPage.ts @@ -3,7 +3,7 @@ import { WithLicenseSummary } from "#elements/mixins/license"; import { updateURLParams } from "#elements/router/RouteMatch"; import "@goauthentik/admin/events/EventMap"; import "@goauthentik/admin/events/EventVolumeChart"; -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventGeo, renderEventUser } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -113,7 +113,7 @@ export class EventListPage extends WithLicenseSummary(TablePage) { return [ html`
${actionToLabel(item.action)}
${item.app}`, - EventUser(item), + renderEventUser(item), html`
${formatElapsedTime(item.created)}
${item.created.toLocaleString()}`, html`
${item.clientIp || msg("-")}
diff --git a/web/src/admin/events/EventMap.ts b/web/src/admin/events/EventMap.ts index 17fdbe546b..e173bc2604 100644 --- a/web/src/admin/events/EventMap.ts +++ b/web/src/admin/events/EventMap.ts @@ -8,6 +8,7 @@ import OlMap from "@openlayers-elements/core/ol-map"; import "@openlayers-elements/maps/ol-layer-openstreetmap"; import "@openlayers-elements/maps/ol-select"; import Feature from "ol/Feature"; +import { isEmpty } from "ol/extent"; import { Point } from "ol/geom"; import { fromLonLat } from "ol/proj"; import Icon from "ol/style/Icon"; @@ -124,6 +125,9 @@ export class EventMap extends AKElement { this.vectorLayer?.source?.addFeature(feature); }); // Zoom to show points better + if (isEmpty(this.vectorLayer.source.getExtent())) { + return; + } this.map.map.getView().fit(this.vectorLayer.source.getExtent(), { padding: [ this.zoomPaddingPx, diff --git a/web/src/admin/events/EventViewPage.ts b/web/src/admin/events/EventViewPage.ts index 45c5badd23..3484e6111b 100644 --- a/web/src/admin/events/EventViewPage.ts +++ b/web/src/admin/events/EventViewPage.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "#admin/events/utils"; +import { EventGeo, renderEventUser } from "#admin/events/utils"; import { DEFAULT_CONFIG } from "#common/api/config"; import { EventWithContext } from "#common/events"; import { actionToLabel } from "#common/labels"; @@ -92,7 +92,7 @@ export class EventViewPage extends AKElement {
- ${EventUser(this.event)} + ${renderEventUser(this.event)}
diff --git a/web/src/admin/events/utils.ts b/web/src/admin/events/utils.ts index 3c73c3f390..26026180e6 100644 --- a/web/src/admin/events/utils.ts +++ b/web/src/admin/events/utils.ts @@ -1,9 +1,9 @@ -import { EventWithContext } from "@goauthentik/common/events"; +import { EventUser, EventWithContext } from "@goauthentik/common/events"; import { truncate } from "@goauthentik/common/utils"; import { SlottedTemplateResult } from "@goauthentik/elements/types"; import { msg, str } from "@lit/localize"; -import { html, nothing } from "lit"; +import { TemplateResult, html, nothing } from "lit"; /** * Given event with a geographical context, format it into a string for display. @@ -18,31 +18,48 @@ export function EventGeo(event: EventWithContext): SlottedTemplateResult { return html`${parts.join(", ")}`; } -export function EventUser( +export function renderEventUser( event: EventWithContext, truncateUsername?: number, ): SlottedTemplateResult { if (!event.user.username) return html`-`; - let body: SlottedTemplateResult = nothing; + const linkOrSpan = (inner: TemplateResult, evu: EventUser) => { + return html`${evu.pk && !evu.is_anonymous + ? html`${inner}` + : html`${inner}`}`; + }; - if (event.user.is_anonymous) { - body = html`
${msg("Anonymous user")}
`; - } else { - body = html``; - } + const renderUsername = (evu: EventUser) => { + let username = evu.username; + if (evu.is_anonymous) { + username = msg("Anonymous user"); + } + if (truncateUsername) { + return truncate(username, truncateUsername); + } + return username; + }; + + let body: SlottedTemplateResult = nothing; + body = html`
${linkOrSpan(html`${renderUsername(event.user)}`, event.user)}
`; if (event.user.on_behalf_of) { return html`${body} - ${msg(str`On behalf of ${event.user.on_behalf_of.username}`)} + ${linkOrSpan( + html`${msg(str`On behalf of ${renderUsername(event.user.on_behalf_of)}`)}`, + event.user.on_behalf_of, + )} + `; + } + if (event.user.authenticated_as) { + return html`${body} + ${linkOrSpan( + html`${msg( + str`Authenticated as ${renderUsername(event.user.authenticated_as)}`, + )}`, + event.user.authenticated_as, + )} `; } diff --git a/web/src/common/events.ts b/web/src/common/events.ts index e07fac5672..4230aa2add 100644 --- a/web/src/common/events.ts +++ b/web/src/common/events.ts @@ -4,8 +4,9 @@ export interface EventUser { pk: number; email?: string; username: string; - on_behalf_of?: EventUser; is_anonymous?: boolean; + on_behalf_of?: EventUser; + authenticated_as?: EventUser; } export interface EventGeo { diff --git a/web/src/components/events/ObjectChangelog.ts b/web/src/components/events/ObjectChangelog.ts index 25739170da..fef7f7d792 100644 --- a/web/src/components/events/ObjectChangelog.ts +++ b/web/src/components/events/ObjectChangelog.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventGeo, renderEventUser } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -72,7 +72,7 @@ export class ObjectChangelog extends Table { row(item: EventWithContext): SlottedTemplateResult[] { return [ html`${actionToLabel(item.action)}`, - EventUser(item), + renderEventUser(item), html`
${formatElapsedTime(item.created)}
${item.created.toLocaleString()}`, html`
${item.clientIp || msg("-")}
diff --git a/web/src/components/events/UserEvents.ts b/web/src/components/events/UserEvents.ts index 1fd5708441..e627166ec5 100644 --- a/web/src/components/events/UserEvents.ts +++ b/web/src/components/events/UserEvents.ts @@ -1,4 +1,4 @@ -import { EventUser } from "@goauthentik/admin/events/utils"; +import { renderEventUser } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -46,7 +46,7 @@ export class UserEvents extends Table { row(item: EventWithContext): SlottedTemplateResult[] { return [ html`${actionToLabel(item.action)}`, - EventUser(item), + renderEventUser(item), html`
${formatElapsedTime(item.created)}
${item.created.toLocaleString()}`, html`${item.clientIp || msg("-")}`,