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["app"] == ""
and attrs["model"] == ""
and attrs["query"] == ""
):
raise ValidationError(_("At least one criteria must be set."))
return super().validate(attrs)
class Meta:
model = EventMatcherPolicy
fields = PolicySerializer.Meta.fields + [
"action",
"client_ip",
"app",
"model",
]
fields = PolicySerializer.Meta.fields + ["action", "client_ip", "app", "model", "query"]
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.utils.translation import gettext as _
from djangoql.queryset import apply_search
from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.api.search.ql import BaseSchema
from authentik.events.models import Event, EventAction
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
@@ -17,6 +19,10 @@ LOGGER = get_logger()
class EventMatcherPolicy(Policy):
"""Passes when Event matches selected criteria."""
query = models.TextField(
null=True,
default=None,
)
action = models.TextField(
choices=EventAction.choices,
null=True,
@@ -69,6 +75,7 @@ class EventMatcherPolicy(Policy):
matches: list[PolicyResult] = []
messages = []
checks = [
self.passes_query,
self.passes_action,
self.passes_client_ip,
self.passes_app,
@@ -90,6 +97,20 @@ class EventMatcherPolicy(Policy):
result.source_results = matches
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:
"""Check if `self.action` matches"""
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")
response = policy.passes(request)
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",
"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": []

View File

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

View File

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

View File

@@ -97,6 +97,12 @@ export interface EventMatcherPolicy {
* @memberof EventMatcherPolicy
*/
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"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
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"],
app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]),
query: value["query"],
};
}

View File

@@ -61,6 +61,12 @@ export interface EventMatcherPolicyRequest {
* @memberof EventMatcherPolicyRequest
*/
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"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
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"],
app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]),
query: value["query"],
};
}

View File

@@ -61,6 +61,12 @@ export interface PatchedEventMatcherPolicyRequest {
* @memberof PatchedEventMatcherPolicyRequest
*/
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"],
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
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"],
app: AppEnumToJSON(value["app"]),
model: ModelEnumToJSON(value["model"]),
query: value["query"],
};
}

View File

@@ -11424,6 +11424,10 @@ paths:
schema:
type: string
format: uuid
- in: query
name: query
schema:
type: string
- $ref: '#/components/parameters/QuerySearch'
tags:
- policies
@@ -38255,6 +38259,9 @@ components:
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
nullable: true
required:
- bound_to
- component
@@ -38299,6 +38306,10 @@ components:
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
nullable: true
minLength: 1
required:
- name
EventRequest:
@@ -47867,6 +47878,10 @@ components:
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
nullable: true
minLength: 1
PatchedEventRequest:
type: object
description: Event Serializer

View File

@@ -4,6 +4,7 @@ import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
import { DEFAULT_CONFIG } from "#common/api/config";
import { docLink } from "#common/global";
import { BasePolicyForm } from "#admin/policies/BasePolicyForm";
@@ -23,13 +24,14 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-policy-event-matcher-form")
export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
loadInstance(pk: string): Promise<EventMatcherPolicy> {
override loadInstance(pk: string): Promise<EventMatcherPolicy> {
return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({
policyUuid: pk,
});
}
async send(data: EventMatcherPolicy): Promise<EventMatcherPolicy> {
if (data.query?.toString() === "") data.query = null;
if (data.action?.toString() === "") data.action = null;
if (data.clientIp?.toString() === "") data.clientIp = null;
if (data.app?.toString() === "") data.app = null;
@@ -70,6 +72,25 @@ export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
</ak-switch-input>
<ak-form-group open label="${msg("Policy-specific settings")}">
<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-search-select
.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 { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
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 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;
#letterWidth = -1;
#scrollContainer: HTMLElement | null = null;
#autocompleteCache: Introspections | null = null;
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;
}
@@ -177,13 +181,16 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
this.#ql = new QL({
completionEnabled: true,
introspections: {
introspections: this.#autocompleteCache || {
current_model: "",
models: {},
},
selector: textarea,
autoResize: false,
});
if (this.#autocompleteCache) {
this.#autocompleteCache = null;
}
const canvas = new OffscreenCanvas(300, 150);
this.#ctx = canvas.getContext("2d");
@@ -478,9 +485,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
@focus=${this.#focusListener}
@blur=${this.#blurListener}
@keydown=${this.#keydownListener}
>
${ifDefined(this.#value)}</textarea
>
.value=${this.#value}
></textarea>
</span>
</div>
${this.renderMenu()}