diff --git a/authentik/policies/event_matcher/api.py b/authentik/policies/event_matcher/api.py index dd982add75..d37220e505 100644 --- a/authentik/policies/event_matcher/api.py +++ b/authentik/policies/event_matcher/api.py @@ -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): diff --git a/authentik/policies/event_matcher/migrations/0027_eventmatcherpolicy_query.py b/authentik/policies/event_matcher/migrations/0027_eventmatcherpolicy_query.py new file mode 100644 index 0000000000..a694d35b43 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0027_eventmatcherpolicy_query.py @@ -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), + ), + ] diff --git a/authentik/policies/event_matcher/models.py b/authentik/policies/event_matcher/models.py index dca673a595..ef38aafd1f 100644 --- a/authentik/policies/event_matcher/models.py +++ b/authentik/policies/event_matcher/models.py @@ -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: diff --git a/authentik/policies/event_matcher/tests.py b/authentik/policies/event_matcher/tests.py index 07922190c9..cc20b13243 100644 --- a/authentik/policies/event_matcher/tests.py +++ b/authentik/policies/event_matcher/tests.py @@ -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.",)) diff --git a/blueprints/schema.json b/blueprints/schema.json index 663af89bee..82673512b6 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -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": [] diff --git a/cspell.config.jsonc b/cspell.config.jsonc index 84c5fa692e..756275854d 100644 --- a/cspell.config.jsonc +++ b/cspell.config.jsonc @@ -138,7 +138,8 @@ "SshRsa", "UnicodeRef", "Email", - "HashStrings" + "HashStrings", + "AKQL" ], "languageSettings": [ { diff --git a/packages/client-ts/src/apis/PoliciesApi.ts b/packages/client-ts/src/apis/PoliciesApi.ts index 7590f27d88..0c68c1d7a3 100644 --- a/packages/client-ts/src/apis/PoliciesApi.ts +++ b/packages/client-ts/src/apis/PoliciesApi.ts @@ -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"]; } diff --git a/packages/client-ts/src/models/EventMatcherPolicy.ts b/packages/client-ts/src/models/EventMatcherPolicy.ts index 2408bd1c17..73fc472fb9 100644 --- a/packages/client-ts/src/models/EventMatcherPolicy.ts +++ b/packages/client-ts/src/models/EventMatcherPolicy.ts @@ -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"], }; } diff --git a/packages/client-ts/src/models/EventMatcherPolicyRequest.ts b/packages/client-ts/src/models/EventMatcherPolicyRequest.ts index cf736571fd..8ce6949d6f 100644 --- a/packages/client-ts/src/models/EventMatcherPolicyRequest.ts +++ b/packages/client-ts/src/models/EventMatcherPolicyRequest.ts @@ -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"], }; } diff --git a/packages/client-ts/src/models/PatchedEventMatcherPolicyRequest.ts b/packages/client-ts/src/models/PatchedEventMatcherPolicyRequest.ts index d3c3934d36..c42c3a6cb4 100644 --- a/packages/client-ts/src/models/PatchedEventMatcherPolicyRequest.ts +++ b/packages/client-ts/src/models/PatchedEventMatcherPolicyRequest.ts @@ -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"], }; } diff --git a/schema.yml b/schema.yml index 49ef628cf1..f5f27ba053 100644 --- a/schema.yml +++ b/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 diff --git a/web/src/admin/policies/event_matcher/EventMatcherPolicyForm.ts b/web/src/admin/policies/event_matcher/EventMatcherPolicyForm.ts index f971ac2e60..9d4aa333d9 100644 --- a/web/src/admin/policies/event_matcher/EventMatcherPolicyForm.ts +++ b/web/src/admin/policies/event_matcher/EventMatcherPolicyForm.ts @@ -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 { - loadInstance(pk: string): Promise { + override loadInstance(pk: string): Promise { return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({ policyUuid: pk, }); } async send(data: EventMatcherPolicy): Promise { + 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 {
+ + +

+ ${msg("Event query using the AKQL syntax.")} + + ${msg("See documentation for examples.")} + +

+
=> { diff --git a/web/src/components/ak-search-ql/index.ts b/web/src/components/ak-search-ql/index.ts index 87e7a744dc..98b9e56caf 100644 --- a/web/src/components/ak-search-ql/index.ts +++ b/web/src/components/ak-search-ql/index.ts @@ -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 implements FormAssoc #ctx: OffscreenCanvasRenderingContext2D | null = null; #letterWidth = -1; #scrollContainer: HTMLElement | null = null; + #autocompleteCache: Introspections | null = null; public set apiResponse(value: PaginatedResponse | 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 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 implements FormAssoc @focus=${this.#focusListener} @blur=${this.#blurListener} @keydown=${this.#keydownListener} - > -${ifDefined(this.#value)} + .value=${this.#value} + >
${this.renderMenu()}