Compare commits

..

4 Commits

Author SHA1 Message Date
396925d1f0 add timeout
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-17 02:43:09 +01:00
10a8ed164e small fixes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-17 02:36:56 +01:00
445dc01dca add full outpost support
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-17 02:36:56 +01:00
441916703d implement adapter using outposts
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-17 02:36:56 +01:00
23 changed files with 376 additions and 425 deletions

View File

@ -128,6 +128,12 @@ class OutpostConsumer(JsonWebsocketConsumer):
state.args.update(msg.args)
elif msg.instruction == WebsocketMessageInstruction.ACK:
return
elif msg.instruction == WebsocketMessageInstruction.PROVIDER_SPECIFIC:
if "response_channel" not in msg.args:
return
self.logger.debug("Posted response to channel", msg=msg)
async_to_sync(self.channel_layer.send)(msg.args.get("response_channel"), content)
return
GAUGE_OUTPOSTS_LAST_UPDATE.labels(
tenant=connection.schema_name,
outpost=self.outpost.name,

View File

@ -0,0 +1,86 @@
from base64 import b64decode
from dataclasses import asdict, dataclass
from random import choice
from typing import Any
from uuid import uuid4
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels_redis.pubsub import RedisPubSubChannelLayer
from requests.adapters import BaseAdapter
from requests.models import PreparedRequest, Response
from requests.utils import CaseInsensitiveDict
from structlog.stdlib import get_logger
from authentik.outposts.models import Outpost
@dataclass
class OutpostPreparedRequest:
uid: str
method: str
url: str
headers: dict[str, str]
body: Any
ssl_verify: bool
timeout: int
@staticmethod
def from_requests(req: PreparedRequest) -> "OutpostPreparedRequest":
return OutpostPreparedRequest(
uid=str(uuid4()),
method=req.method,
url=req.url,
headers=req.headers._store,
body=req.body,
ssl_verify=True,
timeout=0,
)
@property
def response_channel(self) -> str:
return f"authentik_outpost_http_response_{self.uid}"
class OutpostHTTPAdapter(BaseAdapter):
"""Requests Adapter that sends HTTP requests via a specified Outpost"""
def __init__(self, outpost: Outpost, default_timeout=10):
super().__init__()
self.__outpost = outpost
self.__logger = get_logger().bind()
self.__layer: RedisPubSubChannelLayer = get_channel_layer()
self.default_timeout = default_timeout
def parse_response(self, raw_response: dict, req: PreparedRequest) -> Response:
res = Response()
res.request = req
res.status_code = raw_response.get("status")
res.url = raw_response.get("final_url")
res.headers = CaseInsensitiveDict(raw_response.get("headers"))
res._content = b64decode(raw_response.get("body"))
return res
def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
# Convert request so we can send it to the outpost
converted = OutpostPreparedRequest.from_requests(request)
converted.ssl_verify = verify
converted.timeout = timeout if timeout else self.default_timeout
# Pick one of the outpost instances
state = choice(self.__outpost.state) # nosec
self.__logger.debug("sending HTTP request to outpost", uid=converted.uid)
async_to_sync(self.__layer.send)(
state.uid,
{
"type": "event.provider.specific",
"sub_type": "http_request",
"response_channel": converted.response_channel,
"request": asdict(converted),
},
)
self.__logger.debug("receiving HTTP response from outpost", uid=converted.uid)
raw_response = async_to_sync(self.__layer.receive)(
converted.response_channel,
)
self.__logger.debug("received HTTP response from outpost", uid=converted.uid)
return self.parse_response(raw_response.get("args", {}).get("response", {}), request)

View File

@ -98,6 +98,7 @@ class OutpostType(models.TextChoices):
LDAP = "ldap"
RADIUS = "radius"
RAC = "rac"
SCIM = "scim"
def default_outpost_config(host: str | None = None):

View File

@ -43,13 +43,15 @@ from authentik.providers.proxy.controllers.docker import ProxyDockerController
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from authentik.providers.radius.controllers.docker import RadiusDockerController
from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController
from authentik.providers.scim.controllers.docker import SCIMDockerController
from authentik.providers.scim.controllers.kubernetes import SCIMKubernetesController
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s"
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None: # noqa: PLR0911
"""Get a controller for the outpost, when a service connection is defined"""
if not outpost.service_connection:
return None
@ -74,6 +76,11 @@ def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:
return RACDockerController
if isinstance(service_connection, KubernetesServiceConnection):
return RACKubernetesController
if outpost.type == OutpostType.SCIM:
if isinstance(service_connection, DockerServiceConnection):
return SCIMDockerController
if isinstance(service_connection, KubernetesServiceConnection):
return SCIMKubernetesController
return None

View File

@ -1,26 +1,11 @@
"""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):
@ -45,50 +30,3 @@ 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

@ -19,6 +19,7 @@ from authentik.lib.sync.outgoing.exceptions import (
TransientSyncException,
)
from authentik.lib.utils.http import get_http_session
from authentik.outposts.http import OutpostHTTPAdapter
from authentik.providers.scim.clients.exceptions import SCIMRequestException
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
from authentik.providers.scim.models import SCIMProvider
@ -41,8 +42,7 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
def __init__(self, provider: SCIMProvider):
super().__init__(provider)
self._session = get_http_session()
self._session.verify = provider.verify_certificates
self._session = self.get_session(provider)
self.provider = provider
# Remove trailing slashes as we assume the URL doesn't have any
base_url = provider.url
@ -52,6 +52,15 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
self.token = provider.token
self._config = self.get_service_provider_config()
def get_session(self, provider: SCIMProvider):
session = get_http_session()
if self.provider.outpost_set.exists():
adapter = OutpostHTTPAdapter()
session.mount("https://", adapter)
session.mount("http://", adapter)
session.verify = provider.verify_certificates
return session
def _request(self, method: str, path: str, **kwargs) -> dict:
"""Wrapper to send a request to the full URL"""
try:

View File

@ -0,0 +1,12 @@
"""SCIM Provider Docker Controller"""
from authentik.outposts.controllers.docker import DockerController
from authentik.outposts.models import DockerServiceConnection, Outpost
class SCIMDockerController(DockerController):
"""SCIM Provider Docker Controller"""
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
super().__init__(outpost, connection)
self.deployment_ports = []

View File

@ -0,0 +1,14 @@
"""SCIM Provider Kubernetes Controller"""
from authentik.outposts.controllers.k8s.service import ServiceReconciler
from authentik.outposts.controllers.kubernetes import KubernetesController
from authentik.outposts.models import KubernetesServiceConnection, Outpost
class SCIMKubernetesController(KubernetesController):
"""SCIM Provider Kubernetes Controller"""
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
super().__init__(outpost, connection)
self.deployment_ports = []
del self.reconcilers[ServiceReconciler.reconciler_name()]

View File

@ -4381,7 +4381,8 @@
"proxy",
"ldap",
"radius",
"rac"
"rac",
"scim"
],
"title": "Type"
},

185
cmd/scim/main.go Normal file
View File

@ -0,0 +1,185 @@
package main
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/mitchellh/mapstructure"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"goauthentik.io/internal/common"
"goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck"
)
const helpMessage = `authentik SCIM
Required environment variables:
- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company")
- AUTHENTIK_TOKEN: Token to authenticate with
- AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{
Long: helpMessage,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{
FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer()
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
if !found {
fmt.Println("env AUTHENTIK_HOST not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
if !found {
fmt.Println("env AUTHENTIK_TOKEN not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akURLActual, err := url.Parse(akURL)
if err != nil {
fmt.Println(err)
fmt.Println(helpMessage)
os.Exit(1)
}
ex := common.Init()
defer common.Defer()
go func() {
for {
<-ex
os.Exit(0)
}
}()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = &SCIMOutpost{
ac: ac,
log: log.WithField("logger", "authentik.outpost.scim"),
}
err = ac.Start()
if err != nil {
log.WithError(err).Panic("Failed to run server")
}
for {
<-ex
}
},
}
type HTTPRequest struct {
Uid string `mapstructure:"uid"`
Method string `mapstructure:"method"`
URL string `mapstructure:"url"`
Headers map[string][]string `mapstructure:"headers"`
Body interface{} `mapstructure:"body"`
SSLVerify bool `mapstructure:"ssl_verify"`
Timeout int `mapstructure:"timeout"`
}
type RequestArgs struct {
Request HTTPRequest `mapstructure:"request"`
ResponseChannel string `mapstructure:"response_channel"`
}
type SCIMOutpost struct {
ac *ak.APIController
log *log.Entry
}
func (s *SCIMOutpost) Type() string { return "SCIM" }
func (s *SCIMOutpost) Stop() error { return nil }
func (s *SCIMOutpost) Refresh() error { return nil }
func (s *SCIMOutpost) TimerFlowCacheExpiry(context.Context) {}
func (s *SCIMOutpost) Start() error {
s.ac.AddWSHandler(func(ctx context.Context, args map[string]interface{}) {
rd := RequestArgs{}
err := mapstructure.Decode(args, &rd)
if err != nil {
s.log.WithError(err).Warning("failed to parse http request")
return
}
s.log.WithField("rd", rd).WithField("raw", args).Debug("request data")
ctx, canc := context.WithTimeout(ctx, time.Duration(rd.Request.Timeout)*time.Second)
defer canc()
req, err := http.NewRequestWithContext(ctx, rd.Request.Method, rd.Request.URL, nil)
if err != nil {
s.log.WithError(err).Warning("failed to create request")
return
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !rd.Request.SSLVerify},
TLSHandshakeTimeout: time.Duration(rd.Request.Timeout) * time.Second,
IdleConnTimeout: time.Duration(rd.Request.Timeout) * time.Second,
ResponseHeaderTimeout: time.Duration(rd.Request.Timeout) * time.Second,
ExpectContinueTimeout: time.Duration(rd.Request.Timeout) * time.Second,
}
c := &http.Client{
Transport: tr,
}
s.log.WithField("url", req.URL.Host).Debug("sending HTTP request")
res, err := c.Do(req)
if err != nil {
s.log.WithError(err).Warning("failed to send request")
return
}
body, err := io.ReadAll(res.Body)
if err != nil {
s.log.WithError(err).Warning("failed to read body")
return
}
s.log.WithField("res", res.StatusCode).Debug("sending HTTP response")
err = s.ac.SendWS(ak.WebsocketInstructionProviderSpecific, map[string]interface{}{
"sub_type": "http_response",
"response_channel": rd.ResponseChannel,
"response": map[string]interface{}{
"status": res.StatusCode,
"final_url": res.Request.URL.String(),
"headers": res.Header,
"body": base64.StdEncoding.EncodeToString(body),
},
})
if err != nil {
s.log.WithError(err).Warning("failed to send http response")
return
}
})
return nil
}
func main() {
rootCmd.AddCommand(healthcheck.Command)
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View File

@ -95,7 +95,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
time.Sleep(time.Second * 3)
}
if len(outposts.Results) < 1 {
panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost")
}
outpost := outposts.Results[0]

View File

@ -233,15 +233,19 @@ func (a *APIController) AddWSHandler(handler WSHandler) {
a.wsHandlers = append(a.wsHandlers, handler)
}
func (a *APIController) SendWS(inst WebsocketInstruction, args map[string]interface{}) error {
msg := websocketMessage{
Instruction: inst,
Args: args,
}
err := a.wsConn.WriteJSON(msg)
return err
}
func (a *APIController) SendWSHello(args map[string]interface{}) error {
allArgs := a.getWebsocketPingArgs()
for key, value := range args {
allArgs[key] = value
}
aliveMsg := websocketMessage{
Instruction: WebsocketInstructionHello,
Args: allArgs,
}
err := a.wsConn.WriteJSON(aliveMsg)
return err
return a.SendWS(WebsocketInstructionHello, args)
}

View File

@ -1,19 +1,19 @@
package ak
type websocketInstruction int
type WebsocketInstruction int
const (
// WebsocketInstructionAck Code used to acknowledge a previous message
WebsocketInstructionAck websocketInstruction = 0
WebsocketInstructionAck WebsocketInstruction = 0
// WebsocketInstructionHello Code used to send a healthcheck keepalive
WebsocketInstructionHello websocketInstruction = 1
WebsocketInstructionHello WebsocketInstruction = 1
// WebsocketInstructionTriggerUpdate Code received to trigger a config update
WebsocketInstructionTriggerUpdate websocketInstruction = 2
WebsocketInstructionTriggerUpdate WebsocketInstruction = 2
// WebsocketInstructionProviderSpecific Code received to trigger some provider specific function
WebsocketInstructionProviderSpecific websocketInstruction = 3
WebsocketInstructionProviderSpecific WebsocketInstruction = 3
)
type websocketMessage struct {
Instruction websocketInstruction `json:"instruction"`
Instruction WebsocketInstruction `json:"instruction"`
Args map[string]interface{} `json:"args"`
}

View File

@ -12813,43 +12813,6 @@ 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
@ -42317,21 +42280,6 @@ 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
@ -46757,6 +46705,7 @@ components:
- ldap
- radius
- rac
- scim
type: string
PaginatedApplicationEntitlementList:
type: object

View File

@ -73,6 +73,9 @@ const radiusListFetch = async (page: number, search = "") =>
const racListProvider = async (page: number, search = "") =>
provisionMaker(await api().providersRacList(providerListArgs(page, search)));
const scimListProvider = async (page: number, search = "") =>
provisionMaker(await api().providersScimList(providerListArgs(page, search)));
function providerProvider(type: OutpostTypeEnum): DataProvider {
switch (type) {
case OutpostTypeEnum.Proxy:
@ -83,6 +86,8 @@ function providerProvider(type: OutpostTypeEnum): DataProvider {
return radiusListFetch;
case OutpostTypeEnum.Rac:
return racListProvider;
case OutpostTypeEnum.Scim:
return scimListProvider;
default:
throw new Error(`Unrecognized OutputType: ${type}`);
}
@ -142,6 +147,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
[OutpostTypeEnum.Ldap, msg("LDAP")],
[OutpostTypeEnum.Radius, msg("Radius")],
[OutpostTypeEnum.Rac, msg("RAC")],
[OutpostTypeEnum.Scim, msg("SCIM")],
];
return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">

View File

@ -8,7 +8,6 @@ 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";
@ -22,6 +21,7 @@ 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,12 +71,7 @@ export class PolicyListPage extends TablePage<Policy> {
${msg("Warning: Policy is not assigned.")}
</ak-label>`}`,
html`${item.verboseName}`,
html` <ak-forms-modal
size=${item.component === "ak-policy-expression-form"
? PFSize.XLarge
: PFSize.Large}
?fullHeight=${item.component === "ak-policy-expression-form"}
>
html` <ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg(str`Update ${item.verboseName}`)} </span>
<ak-proxy-form
@ -84,7 +79,7 @@ export class PolicyListPage extends TablePage<Policy> {
.args=${{
instancePk: item.pk,
}}
type=${item.component}
type=${ifDefined(item.component)}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-plain">

View File

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

View File

@ -1,64 +1,25 @@
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 { CSSResult, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
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";
import { ExpressionPolicy, PoliciesApi } from "@goauthentik/api";
@customElement("ak-policy-expression-form")
export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> {
@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({
loadInstance(pk: string): Promise<ExpressionPolicy> {
return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionRetrieve({
policyUuid: pk,
});
this.user = await me();
await this.refreshPreview(policy);
return policy;
}
async send(data: ExpressionPolicy): Promise<ExpressionPolicy> {
@ -74,196 +35,10 @@ 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">
@ -305,9 +80,6 @@ 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,11 +20,8 @@ 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 (
@ -49,7 +46,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(
@ -86,16 +83,13 @@ 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,9 +40,6 @@ export class ModalButton extends AKElement {
@property()
size: PFSize = PFSize.Large;
@property({ type: Boolean })
fullHeight = false;
@property({ type: Boolean })
open = false;
@ -72,9 +69,6 @@ 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,20 +14,7 @@ import { LogEvent, LogLevelEnum } from "@goauthentik/api";
@customElement("ak-log-viewer")
export class LogViewer extends Table<LogEvent> {
@property({ attribute: false })
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,
};
}
logs?: LogEvent[] = [];
expandable = true;
paginated = false;
@ -37,20 +24,18 @@ export class LogViewer extends Table<LogEvent> {
}
async apiEndpoint(): Promise<PaginatedResponse<LogEvent>> {
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 || [],
}
);
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 || [],
};
}
renderEmpty(): TemplateResult {

View File

@ -38,10 +38,6 @@ 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