events: use pending_user as user when possible (#15238)
* unrelated: dont show nested for user Signed-off-by: Jens Langhammer <jens@goauthentik.io> * unrelated: fix error when no extents in. map Signed-off-by: Jens Langhammer <jens@goauthentik.io> * events: use pending_user when possible Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix for identification stage "fake" user Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better username rendering Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		| @ -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): | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -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<Event> { | ||||
|         return [ | ||||
|             html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div> | ||||
|                 <small>${item.app}</small>`, | ||||
|             EventUser(item), | ||||
|             renderEventUser(item), | ||||
|             html`<div>${formatElapsedTime(item.created)}</div> | ||||
|                 <small>${item.created.toLocaleString()}</small>`, | ||||
|             html` <div>${item.clientIp || msg("-")}</div> | ||||
|  | ||||
| @ -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<Event>) { | ||||
|         return [ | ||||
|             html`<div>${actionToLabel(item.action)}</div> | ||||
|                 <small>${item.app}</small>`, | ||||
|             EventUser(item), | ||||
|             renderEventUser(item), | ||||
|             html`<div>${formatElapsedTime(item.created)}</div> | ||||
|                 <small>${item.created.toLocaleString()}</small>`, | ||||
|             html`<div>${item.clientIp || msg("-")}</div> | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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 { | ||||
|                                     </dt> | ||||
|                                     <dd class="pf-c-description-list__description"> | ||||
|                                         <div class="pf-c-description-list__text"> | ||||
|                                             ${EventUser(this.event)} | ||||
|                                             ${renderEventUser(this.event)} | ||||
|                                         </div> | ||||
|                                     </dd> | ||||
|                                 </div> | ||||
|  | ||||
| @ -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`<a href="#/identity/users/${evu.pk}">${inner}</a>` | ||||
|             : html`<span>${inner}</span>`}`; | ||||
|     }; | ||||
|  | ||||
|     if (event.user.is_anonymous) { | ||||
|         body = html`<div>${msg("Anonymous user")}</div>`; | ||||
|     } else { | ||||
|         body = html`<div> | ||||
|             <a href="#/identity/users/${event.user.pk}" | ||||
|                 >${truncateUsername | ||||
|                     ? truncate(event.user?.username, truncateUsername) | ||||
|                     : event.user?.username}</a | ||||
|             > | ||||
|         </div>`; | ||||
|     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`<div>${linkOrSpan(html`${renderUsername(event.user)}`, event.user)}</div>`; | ||||
|  | ||||
|     if (event.user.on_behalf_of) { | ||||
|         return html`${body}<small> | ||||
|                 <a href="#/identity/users/${event.user.on_behalf_of.pk}" | ||||
|                     >${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a | ||||
|                 > | ||||
|                 ${linkOrSpan( | ||||
|                     html`${msg(str`On behalf of ${renderUsername(event.user.on_behalf_of)}`)}`, | ||||
|                     event.user.on_behalf_of, | ||||
|                 )} | ||||
|             </small>`; | ||||
|     } | ||||
|     if (event.user.authenticated_as) { | ||||
|         return html`${body}<small> | ||||
|                 ${linkOrSpan( | ||||
|                     html`${msg( | ||||
|                         str`Authenticated as ${renderUsername(event.user.authenticated_as)}`, | ||||
|                     )}`, | ||||
|                     event.user.authenticated_as, | ||||
|                 )} | ||||
|             </small>`; | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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<Event> { | ||||
|     row(item: EventWithContext): SlottedTemplateResult[] { | ||||
|         return [ | ||||
|             html`${actionToLabel(item.action)}`, | ||||
|             EventUser(item), | ||||
|             renderEventUser(item), | ||||
|             html`<div>${formatElapsedTime(item.created)}</div> | ||||
|                 <small>${item.created.toLocaleString()}</small>`, | ||||
|             html`<div>${item.clientIp || msg("-")}</div> | ||||
|  | ||||
| @ -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<Event> { | ||||
|     row(item: EventWithContext): SlottedTemplateResult[] { | ||||
|         return [ | ||||
|             html`${actionToLabel(item.action)}`, | ||||
|             EventUser(item), | ||||
|             renderEventUser(item), | ||||
|             html`<div>${formatElapsedTime(item.created)}</div> | ||||
|                 <small>${item.created.toLocaleString()}</small>`, | ||||
|             html`<span>${item.clientIp || msg("-")}</span>`, | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L.
					Jens L.