Compare commits

...

6 Commits

Author SHA1 Message Date
b3883f7fbf add exec button
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:20:55 +01:00
87c6b0128a full height modal
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:08:15 +01:00
b243c97916 policies/expression: add inline expression tester
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:08:15 +01:00
3f66527521 unrelated cleanup
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:08:15 +01:00
2f7c258657 some real ugly code
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:07:20 +01:00
917c90374f base preview in expr policy
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-16 18:07:20 +01:00
9 changed files with 408 additions and 27 deletions

View File

@ -1,11 +1,26 @@
"""Expression Policy API"""
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.expression.evaluator import PolicyEvaluator
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.policies.process import PolicyProcess
from authentik.policies.types import PolicyRequest
from authentik.rbac.decorators import permission_required
LOGGER = get_logger()
class ExpressionPolicySerializer(PolicySerializer):
@ -30,3 +45,50 @@ class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
class ExpressionPolicyTestSerializer(PolicyTestSerializer):
"""Expression policy test serializer"""
expression = CharField()
@permission_required("authentik_policies.view_policy")
@extend_schema(
request=ExpressionPolicyTestSerializer(),
responses={
200: PolicyTestResultSerializer(),
400: OpenApiResponse(description="Invalid parameters"),
},
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def test(self, request: Request, pk: str) -> Response:
"""Test policy"""
policy = self.get_object()
test_params = self.ExpressionPolicyTestSerializer(data=request.data)
if not test_params.is_valid():
return Response(test_params.errors, status=400)
# User permission check, only allow policy testing for users that are readable
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=test_params.validated_data["user"].pk
)
if not users.exists():
return Response(status=400)
policy.expression = test_params.validated_data["expression"]
p_request = PolicyRequest(users.first())
p_request.debug = True
p_request.set_http_request(self.request)
p_request.context = test_params.validated_data.get("context", {})
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
with capture_logs() as logs:
result = proc.execute()
log_messages = []
for log in logs:
if log.attributes.get("process", "") == "PolicyProcess":
continue
log_messages.append(LogEventSerializer(log).data)
result.log_messages = log_messages
response = PolicyTestResultSerializer(result)
return Response(response.data)

View File

@ -12813,6 +12813,43 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/policies/expression/{policy_uuid}/test/:
post:
operationId: policies_expression_test_create
description: Test policy
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Expression Policy.
required: true
tags:
- policies
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ExpressionPolicyTestRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PolicyTestResult'
description: ''
'400':
description: Invalid parameters
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/policies/expression/{policy_uuid}/used_by/:
get:
operationId: policies_expression_used_by_list
@ -42280,6 +42317,21 @@ components:
required:
- expression
- name
ExpressionPolicyTestRequest:
type: object
description: Expression policy test serializer
properties:
user:
type: integer
context:
type: object
additionalProperties: {}
expression:
type: string
minLength: 1
required:
- expression
- user
ExtraRoleObjectPermission:
type: object
description: User permission with additional object-related data

View File

@ -8,6 +8,7 @@ import "@goauthentik/admin/policies/password/PasswordPolicyForm";
import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
import "@goauthentik/admin/rbac/ObjectPermissionModal";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums";
import { PFColor } from "@goauthentik/elements/Label";
import "@goauthentik/elements/forms/ConfirmationForm";
import "@goauthentik/elements/forms/DeleteBulkForm";
@ -21,7 +22,6 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { PoliciesApi, Policy } from "@goauthentik/api";
@ -71,7 +71,12 @@ export class PolicyListPage extends TablePage<Policy> {
${msg("Warning: Policy is not assigned.")}
</ak-label>`}`,
html`${item.verboseName}`,
html` <ak-forms-modal>
html` <ak-forms-modal
size=${item.component === "ak-policy-expression-form"
? PFSize.XLarge
: PFSize.Large}
?fullHeight=${item.component === "ak-policy-expression-form"}
>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg(str`Update ${item.verboseName}`)} </span>
<ak-proxy-form
@ -79,7 +84,7 @@ export class PolicyListPage extends TablePage<Policy> {
.args=${{
instancePk: item.pk,
}}
type=${ifDefined(item.component)}
type=${item.component}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-plain">

View File

@ -87,7 +87,10 @@ export class PolicyWizard extends AKElement {
slot=${`type-${type.component}-${type.modelName}`}
.sidebarLabel=${() => msg(str`Create ${type.name}`)}
>
<ak-proxy-form type=${type.component}></ak-proxy-form>
<ak-proxy-form
?showPreview=${false}
type=${type.component}
></ak-proxy-form>
</ak-wizard-page-form>
`;
})}

View File

@ -1,25 +1,64 @@
import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import { me } from "@goauthentik/common/users";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { ExpressionPolicy, PoliciesApi } from "@goauthentik/api";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
import {
CoreApi,
CoreUsersListRequest,
ExpressionPolicy,
PoliciesApi,
PolicyTestResult,
ResponseError,
SessionUser,
User,
ValidationErrorFromJSON,
} from "@goauthentik/api";
@customElement("ak-policy-expression-form")
export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> {
loadInstance(pk: string): Promise<ExpressionPolicy> {
return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionRetrieve({
@property({ type: Boolean })
showPreview = true;
@state()
preview?: PolicyTestResult;
@state()
previewError?: string[];
@state()
user?: SessionUser;
@state()
previewLoading = false;
static get styles(): CSSResult[] {
return super.styles.concat(PFGrid, PFStack, PFTitle);
}
async loadInstance(pk: string): Promise<ExpressionPolicy> {
const policy = await new PoliciesApi(DEFAULT_CONFIG).policiesExpressionRetrieve({
policyUuid: pk,
});
this.user = await me();
await this.refreshPreview(policy);
return policy;
}
async send(data: ExpressionPolicy): Promise<ExpressionPolicy> {
@ -35,10 +74,196 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> {
}
}
_shouldRefresh = false;
_timer = 0;
connectedCallback(): void {
super.connectedCallback();
if (!this.showPreview) {
return;
}
// Only check if we should update once a second, to prevent spamming API requests
// when many fields are edited
const minUpdateDelay = 1000;
this._timer = setInterval(() => {
if (this._shouldRefresh) {
this.refreshPreview();
this._shouldRefresh = false;
}
}, minUpdateDelay) as unknown as number;
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (!this.showPreview) {
return;
}
clearTimeout(this._timer);
}
async refreshPreview(policy?: ExpressionPolicy): Promise<void> {
if (!policy) {
policy = this.serializeForm();
if (!policy) {
return;
}
}
this.previewLoading = true;
try {
interface testpolicy {
expression: string;
user?: number;
context?: { [key: string]: unknown };
}
const tp = policy as unknown as testpolicy;
this.preview = await new PoliciesApi(DEFAULT_CONFIG).policiesExpressionTestCreate({
expressionPolicyTestRequest: {
expression: tp.expression,
user: tp.user || this.user?.user.pk || 0,
context: tp.context || {},
},
policyUuid: this.instancePk || "",
});
this.previewError = undefined;
} catch (exc) {
const errorMessage = ValidationErrorFromJSON(
await (exc as ResponseError).response.json(),
);
this.previewError = errorMessage.nonFieldErrors;
} finally {
this.previewLoading = false;
}
}
renderForm(): TemplateResult {
return html`<div class="pf-l-grid pf-m-gutter">
<div class="pf-l-grid__item pf-m-6-col pf-l-stack">
<div class="pf-c-form pf-m-horizontal pf-l-stack__item">
${this.renderEditForm()}
</div>
</div>
<div class="pf-l-grid__item pf-m-6-col">${this.renderPreview()}</div>
</div> `;
}
renderPreview(): TemplateResult {
return html`
<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Test parameters")}</div>
<div class="pf-c-card__body pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("User")} name="user">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(
args,
);
return users.results;
}}
.renderElement=${(user: User): string => {
return user.username;
}}
.renderDescription=${(user: User): TemplateResult => {
return html`${user.name}`;
}}
.value=${(user: User | undefined): number | undefined => {
return user?.pk;
}}
.selected=${(user: User): boolean => {
return this.user?.user.pk === user.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Context")} name="context">
<ak-codemirror mode=${CodeMirrorMode.YAML} value=${YAML.stringify({})}>
</ak-codemirror>
</ak-form-element-horizontal>
</div>
<div class="pf-c-card__footer">
<button
class="pf-c-button pf-m-primary"
@click=${() => {
this.refreshPreview();
}}
>
${msg("Execute")}
</button>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Test results")}</div>
${this.previewLoading
? html`<ak-empty-state loading></ak-empty-state>`
: html`<div class="pf-c-card__body pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Passing")}>
<div class="pf-c-form__group-label">
<div class="c-form__horizontal-group">
<span class="pf-c-form__label-text">
<ak-status-label
?good=${this.preview?.passing}
></ak-status-label>
</span>
</div>
</div>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Messages")}>
<div class="pf-c-form__group-label">
<div class="c-form__horizontal-group">
<ul>
${(this.preview?.messages || []).length > 0
? this.preview?.messages?.map((m) => {
return html`<li>
<span class="pf-c-form__label-text"
>${m}</span
>
</li>`;
})
: html`<li>
<span class="pf-c-form__label-text">-</span>
</li>`}
</ul>
</div>
</div>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Log messages")}>
<div class="pf-c-form__group-label">
<div class="c-form__horizontal-group">
<dl class="pf-c-description-list pf-m-horizontal">
<ak-log-viewer
.logs=${this.preview?.logMessages}
></ak-log-viewer>
</dl>
</div>
</div>
</ak-form-element-horizontal>
</div>`}
</div>
${this.previewError
? html`
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">${msg("Preview errors")}</div>
<div class="pf-c-card__body">
${this.previewError.map((err) => html`<pre>${err}</pre>`)}
</div>
</div>
`
: nothing}
</div>
`;
}
renderEditForm(): TemplateResult {
return html` <span>
${msg(
"Executes the python snippet to determine whether to allow or deny a request.",
"Executes the Python snippet to determine whether to allow or deny a request.",
)}
</span>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
@ -80,6 +305,9 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> {
<ak-codemirror
mode=${CodeMirrorMode.Python}
value="${ifDefined(this.instance?.expression)}"
@change=${() => {
this._shouldRefresh = true;
}}
>
</ak-codemirror>
<p class="pf-c-form__helper-text">

View File

@ -20,8 +20,11 @@ export class EnterpriseStatusBanner extends WithLicenseSummary(AKElement) {
}
renderStatusBanner() {
if (!this.licenseSummary) {
return nothing;
}
// Check if we're in the correct interface to render a banner
switch (this.licenseSummary.status) {
switch (this.licenseSummary?.status) {
// user warning is both on admin interface and user interface
case LicenseSummaryStatusEnum.LimitExceededUser:
if (
@ -46,7 +49,7 @@ export class EnterpriseStatusBanner extends WithLicenseSummary(AKElement) {
break;
}
let message = "";
switch (this.licenseSummary.status) {
switch (this.licenseSummary?.status) {
case LicenseSummaryStatusEnum.LimitExceededAdmin:
case LicenseSummaryStatusEnum.LimitExceededUser:
message = msg(
@ -83,13 +86,16 @@ export class EnterpriseStatusBanner extends WithLicenseSummary(AKElement) {
}
renderFlagBanner() {
if (!this.licenseSummary) {
return nothing;
}
return html`
${this.licenseSummary.licenseFlags.includes(LicenseFlagsEnum.Trial)
${this.licenseSummary?.licenseFlags.includes(LicenseFlagsEnum.Trial)
? html`<div class="pf-c-banner pf-m-sticky pf-m-gold">
${msg("This authentik instance uses a Trial license.")}
</div>`
: nothing}
${this.licenseSummary.licenseFlags.includes(LicenseFlagsEnum.NonProduction)
${this.licenseSummary?.licenseFlags.includes(LicenseFlagsEnum.NonProduction)
? html`<div class="pf-c-banner pf-m-sticky pf-m-gold">
${msg("This authentik instance uses a Non-production license.")}
</div>`

View File

@ -40,6 +40,9 @@ export class ModalButton extends AKElement {
@property()
size: PFSize = PFSize.Large;
@property({ type: Boolean })
fullHeight = false;
@property({ type: Boolean })
open = false;
@ -69,6 +72,9 @@ export class ModalButton extends AKElement {
.pf-c-modal-box.pf-m-xl {
--pf-c-modal-box--Width: calc(1.5 * var(--pf-c-modal-box--m-lg--lg--MaxWidth));
}
:host([fullHeight]) .pf-c-modal-box {
height: 100%;
}
`,
];
}

View File

@ -14,7 +14,20 @@ import { LogEvent, LogLevelEnum } from "@goauthentik/api";
@customElement("ak-log-viewer")
export class LogViewer extends Table<LogEvent> {
@property({ attribute: false })
logs?: LogEvent[] = [];
set logs(val: LogEvent[]) {
this.data = {
pagination: {
next: 0,
previous: 0,
count: val.length || 0,
current: 1,
totalPages: 1,
startIndex: 1,
endIndex: val.length || 0,
},
results: val,
};
}
expandable = true;
paginated = false;
@ -24,18 +37,20 @@ export class LogViewer extends Table<LogEvent> {
}
async apiEndpoint(): Promise<PaginatedResponse<LogEvent>> {
return {
pagination: {
next: 0,
previous: 0,
count: this.logs?.length || 0,
current: 1,
totalPages: 1,
startIndex: 1,
endIndex: this.logs?.length || 0,
},
results: this.logs || [],
};
return (
this.data || {
pagination: {
next: 0,
previous: 0,
count: this.logs?.length || 0,
current: 1,
totalPages: 1,
startIndex: 1,
endIndex: this.logs?.length || 0,
},
results: this.logs || [],
}
);
}
renderEmpty(): TemplateResult {

View File

@ -38,6 +38,10 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
});
}
get instancePk(): PKT | undefined {
return this._instancePk;
}
private _instancePk?: PKT;
// Keep track if we've loaded the model instance