mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",))
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -138,7 +138,8 @@
|
||||
"SshRsa",
|
||||
"UnicodeRef",
|
||||
"Email",
|
||||
"HashStrings"
|
||||
"HashStrings",
|
||||
"AKQL"
|
||||
],
|
||||
"languageSettings": [
|
||||
{
|
||||
|
||||
5
packages/client-ts/src/apis/PoliciesApi.ts
generated
5
packages/client-ts/src/apis/PoliciesApi.ts
generated
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
15
schema.yml
15
schema.yml
@@ -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
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user