policies/event_matcher: Add query option to filter events (#21618)

* policies/event_matcher: support QL query

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

* fix lit dev warning

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

* cache autocomplete data if QL isn't setup yet

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

* add ui

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

* dont use ql input in modal

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

* cleanup

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

* fix codespell

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-04-16 00:52:11 +01:00
committed by GitHub
parent 10b39a3fb1
commit 00639d9596
13 changed files with 140 additions and 14 deletions

View File

@@ -40,18 +40,14 @@ class EventMatcherPolicySerializer(PolicySerializer):
and attrs["client_ip"] == "" and attrs["client_ip"] == ""
and attrs["app"] == "" and attrs["app"] == ""
and attrs["model"] == "" and attrs["model"] == ""
and attrs["query"] == ""
): ):
raise ValidationError(_("At least one criteria must be set.")) raise ValidationError(_("At least one criteria must be set."))
return super().validate(attrs) return super().validate(attrs)
class Meta: class Meta:
model = EventMatcherPolicy model = EventMatcherPolicy
fields = PolicySerializer.Meta.fields + [ fields = PolicySerializer.Meta.fields + ["action", "client_ip", "app", "model", "query"]
"action",
"client_ip",
"app",
"model",
]
class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet): class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-04-12 19:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0026_alter_eventmatcherpolicy_action"),
]
operations = [
migrations.AddField(
model_name="eventmatcherpolicy",
name="query",
field=models.TextField(default=None, null=True),
),
]

View File

@@ -4,9 +4,11 @@ from itertools import chain
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from djangoql.queryset import apply_search
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.search.ql import BaseSchema
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.policies.models import Policy from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
@@ -17,6 +19,10 @@ LOGGER = get_logger()
class EventMatcherPolicy(Policy): class EventMatcherPolicy(Policy):
"""Passes when Event matches selected criteria.""" """Passes when Event matches selected criteria."""
query = models.TextField(
null=True,
default=None,
)
action = models.TextField( action = models.TextField(
choices=EventAction.choices, choices=EventAction.choices,
null=True, null=True,
@@ -69,6 +75,7 @@ class EventMatcherPolicy(Policy):
matches: list[PolicyResult] = [] matches: list[PolicyResult] = []
messages = [] messages = []
checks = [ checks = [
self.passes_query,
self.passes_action, self.passes_action,
self.passes_client_ip, self.passes_client_ip,
self.passes_app, self.passes_app,
@@ -90,6 +97,20 @@ class EventMatcherPolicy(Policy):
result.source_results = matches result.source_results = matches
return result return result
def passes_query(self, request: PolicyRequest, event: Event) -> PolicyResult | None:
"""Check AKQL query"""
if not self.query:
return None
from authentik.events.api.events import EventViewSet
class InlineSchema(BaseSchema):
def get_fields(self, model):
return EventViewSet().get_ql_fields()
print(Event.objects.filter(pk=event.pk))
qs = apply_search(Event.objects.filter(pk=event.pk), self.query, InlineSchema)
return PolicyResult(qs.exists(), "Query matched.")
def passes_action(self, request: PolicyRequest, event: Event) -> PolicyResult | None: def passes_action(self, request: PolicyRequest, event: Event) -> PolicyResult | None:
"""Check if `self.action` matches""" """Check if `self.action` matches"""
if self.action is None: if self.action is None:

View File

@@ -101,3 +101,14 @@ class TestEventMatcherPolicy(TestCase):
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(client_ip="1.2.3.4") policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(client_ip="1.2.3.4")
response = policy.passes(request) response = policy.passes(request)
self.assertFalse(response.passing) self.assertFalse(response.passing)
def test_match_query(self):
"""Test match query"""
event = Event.new(EventAction.LOGIN)
event.save()
request = PolicyRequest(get_anonymous_user())
request.context["event"] = event
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(query='action = "login"')
response = policy.passes(request)
self.assertTrue(response.passing)
self.assertTupleEqual(response.messages, ("Query matched.",))

View File

@@ -9072,6 +9072,14 @@
], ],
"title": "Model", "title": "Model",
"description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched." "description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched."
},
"query": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Query"
} }
}, },
"required": [] "required": []

View File

@@ -138,7 +138,8 @@
"SshRsa", "SshRsa",
"UnicodeRef", "UnicodeRef",
"Email", "Email",
"HashStrings" "HashStrings",
"AKQL"
], ],
"languageSettings": [ "languageSettings": [
{ {

View File

@@ -239,6 +239,7 @@ export interface PoliciesEventMatcherListRequest {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
policyUuid?: string; policyUuid?: string;
query?: string;
search?: string; search?: string;
} }
@@ -2220,6 +2221,10 @@ export class PoliciesApi extends runtime.BaseAPI {
queryParameters["policy_uuid"] = requestParameters["policyUuid"]; queryParameters["policy_uuid"] = requestParameters["policyUuid"];
} }
if (requestParameters["query"] != null) {
queryParameters["query"] = requestParameters["query"];
}
if (requestParameters["search"] != null) { if (requestParameters["search"] != null) {
queryParameters["search"] = requestParameters["search"]; queryParameters["search"] = requestParameters["search"];
} }

View File

@@ -97,6 +97,12 @@ export interface EventMatcherPolicy {
* @memberof EventMatcherPolicy * @memberof EventMatcherPolicy
*/ */
model?: ModelEnum | null; model?: ModelEnum | null;
/**
*
* @type {string}
* @memberof EventMatcherPolicy
*/
query?: string | null;
} }
/** /**
@@ -137,6 +143,7 @@ export function EventMatcherPolicyFromJSONTyped(
clientIp: json["client_ip"] == null ? undefined : json["client_ip"], clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]), app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]), model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
query: json["query"] == null ? undefined : json["query"],
}; };
} }
@@ -162,5 +169,6 @@ export function EventMatcherPolicyToJSONTyped(
client_ip: value["clientIp"], client_ip: value["clientIp"],
app: AppEnumToJSON(value["app"]), app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]), model: ModelEnumToJSON(value["model"]),
query: value["query"],
}; };
} }

View File

@@ -61,6 +61,12 @@ export interface EventMatcherPolicyRequest {
* @memberof EventMatcherPolicyRequest * @memberof EventMatcherPolicyRequest
*/ */
model?: ModelEnum | null; model?: ModelEnum | null;
/**
*
* @type {string}
* @memberof EventMatcherPolicyRequest
*/
query?: string | null;
} }
/** /**
@@ -91,6 +97,7 @@ export function EventMatcherPolicyRequestFromJSONTyped(
clientIp: json["client_ip"] == null ? undefined : json["client_ip"], clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]), app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]), model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
query: json["query"] == null ? undefined : json["query"],
}; };
} }
@@ -113,5 +120,6 @@ export function EventMatcherPolicyRequestToJSONTyped(
client_ip: value["clientIp"], client_ip: value["clientIp"],
app: AppEnumToJSON(value["app"]), app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]), model: ModelEnumToJSON(value["model"]),
query: value["query"],
}; };
} }

View File

@@ -61,6 +61,12 @@ export interface PatchedEventMatcherPolicyRequest {
* @memberof PatchedEventMatcherPolicyRequest * @memberof PatchedEventMatcherPolicyRequest
*/ */
model?: ModelEnum | null; model?: ModelEnum | null;
/**
*
* @type {string}
* @memberof PatchedEventMatcherPolicyRequest
*/
query?: string | null;
} }
/** /**
@@ -92,6 +98,7 @@ export function PatchedEventMatcherPolicyRequestFromJSONTyped(
clientIp: json["client_ip"] == null ? undefined : json["client_ip"], clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]), app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]), model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
query: json["query"] == null ? undefined : json["query"],
}; };
} }
@@ -116,5 +123,6 @@ export function PatchedEventMatcherPolicyRequestToJSONTyped(
client_ip: value["clientIp"], client_ip: value["clientIp"],
app: AppEnumToJSON(value["app"]), app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]), model: ModelEnumToJSON(value["model"]),
query: value["query"],
}; };
} }

View File

@@ -11424,6 +11424,10 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- in: query
name: query
schema:
type: string
- $ref: '#/components/parameters/QuerySearch' - $ref: '#/components/parameters/QuerySearch'
tags: tags:
- policies - policies
@@ -38255,6 +38259,9 @@ components:
description: Match events created by selected model. When left empty, all description: Match events created by selected model. When left empty, all
models are matched. When an app is selected, all the application's models models are matched. When an app is selected, all the application's models
are matched. are matched.
query:
type: string
nullable: true
required: required:
- bound_to - bound_to
- component - component
@@ -38299,6 +38306,10 @@ components:
description: Match events created by selected model. When left empty, all description: Match events created by selected model. When left empty, all
models are matched. When an app is selected, all the application's models models are matched. When an app is selected, all the application's models
are matched. are matched.
query:
type: string
nullable: true
minLength: 1
required: required:
- name - name
EventRequest: EventRequest:
@@ -47867,6 +47878,10 @@ components:
description: Match events created by selected model. When left empty, all description: Match events created by selected model. When left empty, all
models are matched. When an app is selected, all the application's models models are matched. When an app is selected, all the application's models
are matched. are matched.
query:
type: string
nullable: true
minLength: 1
PatchedEventRequest: PatchedEventRequest:
type: object type: object
description: Event Serializer description: Event Serializer

View File

@@ -4,6 +4,7 @@ import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index"; import "#elements/forms/SearchSelect/index";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { docLink } from "#common/global";
import { BasePolicyForm } from "#admin/policies/BasePolicyForm"; import { BasePolicyForm } from "#admin/policies/BasePolicyForm";
@@ -23,13 +24,14 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-policy-event-matcher-form") @customElement("ak-policy-event-matcher-form")
export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> { export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
loadInstance(pk: string): Promise<EventMatcherPolicy> { override loadInstance(pk: string): Promise<EventMatcherPolicy> {
return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({ return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({
policyUuid: pk, policyUuid: pk,
}); });
} }
async send(data: EventMatcherPolicy): Promise<EventMatcherPolicy> { async send(data: EventMatcherPolicy): Promise<EventMatcherPolicy> {
if (data.query?.toString() === "") data.query = null;
if (data.action?.toString() === "") data.action = null; if (data.action?.toString() === "") data.action = null;
if (data.clientIp?.toString() === "") data.clientIp = null; if (data.clientIp?.toString() === "") data.clientIp = null;
if (data.app?.toString() === "") data.app = null; if (data.app?.toString() === "") data.app = null;
@@ -70,6 +72,25 @@ export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
</ak-switch-input> </ak-switch-input>
<ak-form-group open label="${msg("Policy-specific settings")}"> <ak-form-group open label="${msg("Policy-specific settings")}">
<div class="pf-c-form"> <div class="pf-c-form">
<ak-form-element-horizontal label=${msg("Query")} name="query">
<input
type="text"
value="${ifDefined(this.instance?.query || "")}"
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"
/>
<p class="pf-c-form__helper-text">
${msg("Event query using the AKQL syntax.")}
<a
rel="noopener noreferrer"
target="_blank"
href=${docLink("/sys-mgmt/events/logging-events/#advanced-queries")}
>
${msg("See documentation for examples.")}
</a>
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Action")} name="action"> <ak-form-element-horizontal label=${msg("Action")} name="action">
<ak-search-select <ak-search-select
.fetchObjects=${async (query?: string): Promise<TypeCreate[]> => { .fetchObjects=${async (query?: string): Promise<TypeCreate[]> => {

View File

@@ -15,7 +15,6 @@ import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref, Ref } from "lit/directives/ref.js"; import { createRef, ref, Ref } from "lit/directives/ref.js";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
@@ -114,9 +113,14 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
#ctx: OffscreenCanvasRenderingContext2D | null = null; #ctx: OffscreenCanvasRenderingContext2D | null = null;
#letterWidth = -1; #letterWidth = -1;
#scrollContainer: HTMLElement | null = null; #scrollContainer: HTMLElement | null = null;
#autocompleteCache: Introspections | null = null;
public set apiResponse(value: PaginatedResponse<unknown> | undefined) { public set apiResponse(value: PaginatedResponse<unknown> | undefined) {
if (!value?.autocomplete || !this.#ql) { if (!value?.autocomplete) {
return;
}
if (!this.#ql) {
this.#autocompleteCache = value.autocomplete as unknown as Introspections;
return; return;
} }
@@ -177,13 +181,16 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
this.#ql = new QL({ this.#ql = new QL({
completionEnabled: true, completionEnabled: true,
introspections: { introspections: this.#autocompleteCache || {
current_model: "", current_model: "",
models: {}, models: {},
}, },
selector: textarea, selector: textarea,
autoResize: false, autoResize: false,
}); });
if (this.#autocompleteCache) {
this.#autocompleteCache = null;
}
const canvas = new OffscreenCanvas(300, 150); const canvas = new OffscreenCanvas(300, 150);
this.#ctx = canvas.getContext("2d"); this.#ctx = canvas.getContext("2d");
@@ -478,9 +485,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
@focus=${this.#focusListener} @focus=${this.#focusListener}
@blur=${this.#blurListener} @blur=${this.#blurListener}
@keydown=${this.#keydownListener} @keydown=${this.#keydownListener}
> .value=${this.#value}
${ifDefined(this.#value)}</textarea ></textarea>
>
</span> </span>
</div> </div>
${this.renderMenu()} ${this.renderMenu()}