core: user paths (#3085)

* init

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add user_path_template

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add to sources and flow

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add outposts & api

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* dark theme for treeview

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add search

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add docs and tests for validation

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add to user write stage

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add web ui

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web: improve error handling

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-06-15 12:12:26 +02:00 committed by GitHub
parent c4b4c7134d
commit 1c62a3db6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 771 additions and 127 deletions

View File

@ -106,7 +106,7 @@ run:
web-build: web-install
cd web && npm run build
web: web-lint-fix web-lint web-extract
web: web-lint-fix web-lint
web-install:
cd web && npm ci

View File

@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"policy_engine_mode",
"user_matching_mode",
"managed",
"user_path_template",
]

View File

@ -24,7 +24,7 @@ from drf_spectacular.utils import (
)
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.fields import CharField, JSONField, ListField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
@ -50,6 +50,7 @@ from authentik.core.middleware import (
from authentik.core.models import (
USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
Group,
Token,
TokenIntents,
@ -77,6 +78,15 @@ class UserSerializer(ModelSerializer):
uid = CharField(read_only=True)
username = CharField(max_length=150)
def validate_path(self, path: str) -> str:
"""Validate path"""
if path[:1] == "/" or path[-1] == "/":
raise ValidationError(_("No leading or trailing slashes allowed."))
for segment in path.split("/"):
if segment == "":
raise ValidationError(_("No empty segments in user path allowed."))
return path
class Meta:
model = User
@ -93,6 +103,7 @@ class UserSerializer(ModelSerializer):
"avatar",
"attributes",
"uid",
"path",
]
extra_kwargs = {
"name": {"allow_blank": True},
@ -208,6 +219,11 @@ class UsersFilter(FilterSet):
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = CharFilter(field_name="uuid")
path = CharFilter(
field_name="path",
)
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
groups_by_name = ModelMultipleChoiceFilter(
field_name="ak_groups__name",
to_field_name="name",
@ -314,6 +330,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
username=username,
name=username,
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
path=USER_PATH_SERVICE_ACCOUNT,
)
if create_group and self.request.user.has_perm("authentik_core.add_group"):
group = Group.objects.create(
@ -464,3 +481,32 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if self.request.user.has_perm("authentik_core.view_user"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)
@extend_schema(
responses={
200: inline_serializer(
"UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)}
)
},
parameters=[
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
)
],
)
@action(detail=False, pagination_class=None)
def paths(self, request: Request) -> Response:
"""Get all user paths"""
return Response(
data={
"paths": list(
self.filter_queryset(self.get_queryset())
.values("path")
.distinct()
.order_by("path")
.values_list("path", flat=True)
)
}
)

View File

@ -12,9 +12,9 @@ import authentik.core.models
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# We have to use a direct import here, otherwise we get an object manager error
from authentik.core.models import User
from django.contrib.auth.hashers import make_password
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
akadmin, _ = User.objects.using(db_alias).get_or_create(
@ -28,9 +28,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password:
akadmin.set_password(password, signal=False)
akadmin.password = make_password(password)
else:
akadmin.set_unusable_password()
akadmin.password = make_password(None)
akadmin.save()

View File

@ -8,9 +8,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# We have to use a direct import here, otherwise we get an object manager error
from authentik.core.models import User
from django.contrib.auth.hashers import make_password
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
akadmin, _ = User.objects.using(db_alias).get_or_create(
@ -24,9 +24,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password:
akadmin.set_password(password, signal=False)
akadmin.password = make_password(password)
else:
akadmin.set_unusable_password()
akadmin.password = make_password(None)
akadmin.save()

View File

@ -0,0 +1,23 @@
# Generated by Django 4.0.5 on 2022-06-13 18:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0020_application_open_in_new_tab"),
]
operations = [
migrations.AddField(
model_name="source",
name="user_path_template",
field=models.TextField(default="goauthentik.io/sources/%(slug)s"),
),
migrations.AddField(
model_name="user",
name="path",
field=models.TextField(default="users"),
),
]

View File

@ -46,6 +46,9 @@ USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
@ -138,6 +141,7 @@ class User(GuardianUserMixin, AbstractUser):
uuid = models.UUIDField(default=uuid4, editable=False)
name = models.TextField(help_text=_("User's display name."))
path = models.TextField(default="users")
sources = models.ManyToManyField("Source", through="UserSourceConnection")
ak_groups = models.ManyToManyField("Group", related_name="users")
@ -147,6 +151,11 @@ class User(GuardianUserMixin, AbstractUser):
objects = UserManager()
@staticmethod
def default_path() -> str:
"""Get the default user path"""
return User._meta.get_field("path").default
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes"""
@ -373,6 +382,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
name = models.TextField(help_text=_("Source's display Name."))
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
enabled = models.BooleanField(default=True)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
@ -408,6 +419,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
objects = InheritanceManager()
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""
try:
return self.user_path_template % {
"slug": self.slug,
}
# pylint: disable=broad-except
except Exception as exc:
LOGGER.warning("Failed to template user path", exc=exc, source=self)
return User.default_path()
@property
def component(self) -> str:
"""Return component used to edit this object"""

View File

@ -31,6 +31,7 @@ from authentik.policies.utils import delete_none_keys
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
class Action(Enum):
@ -291,5 +292,6 @@ class SourceFlowManager:
connection,
**{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
},
)

View File

@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_key
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
@ -149,3 +149,65 @@ class TestUsersAPI(APITestCase):
},
)
self.assertEqual(response.status_code, 400)
def test_paths(self):
"""Test path"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-paths"),
)
print(response.content)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(response.content.decode(), {"paths": ["users"]})
def test_path_valid(self):
"""Test path"""
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-list"),
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
)
self.assertEqual(response.status_code, 201)
def test_path_invalid(self):
"""Test path (invalid)"""
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-list"),
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
)
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-list"),
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
response = self.client.post(
reverse("authentik_api:user-list"),
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
)
response = self.client.post(
reverse("authentik_api:user-list"),
data={
"name": generate_id(),
"username": generate_id(),
"groups": [],
"path": "fos//o",
},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content.decode(), {"path": ["No empty segments in user path allowed."]}
)

View File

@ -20,6 +20,7 @@ from authentik import __version__, get_build_hash
from authentik.core.models import (
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
USER_ATTRIBUTE_SA,
USER_PATH_SYSTEM_PREFIX,
Provider,
Token,
TokenIntents,
@ -39,6 +40,8 @@ OUR_VERSION = parse(__version__)
OUTPOST_HELLO_INTERVAL = 10
LOGGER = get_logger()
USER_PATH_OUTPOSTS = USER_PATH_SYSTEM_PREFIX + "/outposts"
class ServiceConnectionInvalid(SentryIgnoredException):
"""Exception raised when a Service Connection has invalid parameters"""
@ -339,6 +342,7 @@ class Outpost(ManagedModel):
user.attributes[USER_ATTRIBUTE_SA] = True
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
user.name = f"Outpost {self.name} Service-Account"
user.path = USER_PATH_OUTPOSTS
user.save()
if should_create_user:
self.build_user_permissions(user)

View File

@ -64,7 +64,9 @@ class BaseLDAPSynchronizer:
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings."""
return self._build_object_properties(user_dn, self._source.property_mappings, **kwargs)
props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs)
props["path"] = self._source.get_user_path()
return props
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for Group object based on property mappings."""

View File

@ -146,6 +146,7 @@ class ResponseProcessor:
USER_ATTRIBUTE_DELETE_ON_LOGOUT: True,
USER_ATTRIBUTE_EXPIRES: expiry,
},
path=self._source.get_user_path(),
)
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
user.set_unusable_password()

View File

@ -12,7 +12,11 @@ class UserWriteStageSerializer(StageSerializer):
class Meta:
model = UserWriteStage
fields = StageSerializer.Meta.fields + ["create_users_as_inactive", "create_users_group"]
fields = StageSerializer.Meta.fields + [
"create_users_as_inactive",
"create_users_group",
"user_path_template",
]
class UserWriteStageViewSet(UsedByMixin, ModelViewSet):

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.5 on 2022-06-14 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_user_write", "0004_userwritestage_create_users_group"),
]
operations = [
migrations.AddField(
model_name="userwritestage",
name="user_path_template",
field=models.TextField(default="", blank=True),
),
]

View File

@ -26,6 +26,11 @@ class UserWriteStage(Stage):
help_text=_("Optionally add newly created users to this group."),
)
user_path_template = models.TextField(
default="",
blank=True,
)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.user_write.api import UserWriteStageSerializer

View File

@ -19,6 +19,7 @@ from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.signals import user_write
PLAN_CONTEXT_GROUPS = "groups"
PLAN_CONTEXT_USER_PATH = "user_path"
class UserWriteStageView(StageView):
@ -49,9 +50,15 @@ class UserWriteStageView(StageView):
def ensure_user(self) -> tuple[User, bool]:
"""Ensure a user exists"""
user_created = False
path = self.executor.plan.context.get(
PLAN_CONTEXT_USER_PATH, self.executor.current_stage.user_path_template
)
if path == "":
path = User.default_path()
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
is_active=not self.executor.current_stage.create_users_as_inactive
is_active=not self.executor.current_stage.create_users_as_inactive,
path=path,
)
self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
self.logger.debug(

View File

@ -3067,6 +3067,14 @@ paths:
description: Number of results to return per page.
schema:
type: integer
- in: query
name: path
schema:
type: string
- in: query
name: path_startswith
schema:
type: string
- name: search
required: false
in: query
@ -3390,6 +3398,30 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
/core/users/paths/:
get:
operationId: core_users_paths_retrieve
description: Get all user paths
parameters:
- in: query
name: search
schema:
type: string
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserPath'
description: ''
'400':
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
/core/users/service_account/:
post:
operationId: core_users_service_account_create
@ -13133,6 +13165,10 @@ paths:
- username_link
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
- in: query
name: user_path_template
schema:
type: string
tags:
- sources
security:
@ -18826,6 +18862,10 @@ paths:
schema:
type: string
format: uuid
- in: query
name: user_path_template
schema:
type: string
tags:
- stages
security:
@ -22705,6 +22745,8 @@ components:
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
readOnly: true
user_path_template:
type: string
server_uri:
type: string
format: uri
@ -22808,6 +22850,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
server_uri:
type: string
minLength: 1
@ -23417,6 +23462,8 @@ components:
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
readOnly: true
user_path_template:
type: string
provider_type:
$ref: '#/components/schemas/ProviderTypeEnum'
request_token_url:
@ -23504,6 +23551,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
provider_type:
$ref: '#/components/schemas/ProviderTypeEnum'
request_token_url:
@ -27500,6 +27550,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
server_uri:
type: string
minLength: 1
@ -27734,6 +27787,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
provider_type:
$ref: '#/components/schemas/ProviderTypeEnum'
request_token_url:
@ -27938,6 +27994,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
client_id:
type: string
minLength: 1
@ -28251,6 +28310,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
pre_authentication_flow:
type: string
format: uuid
@ -28519,6 +28581,9 @@ components:
attributes:
type: object
additionalProperties: {}
path:
type: string
minLength: 1
PatchedUserWriteStageRequest:
type: object
description: UserWriteStage Serializer
@ -28538,6 +28603,8 @@ components:
format: uuid
nullable: true
description: Optionally add newly created users to this group.
user_path_template:
type: string
PatchedWebAuthnDeviceRequest:
type: object
description: Serializer for WebAuthn authenticator devices
@ -28647,6 +28714,8 @@ components:
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
readOnly: true
user_path_template:
type: string
client_id:
type: string
description: Client identifier used to talk to Plex.
@ -28743,6 +28812,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
client_id:
type: string
minLength: 1
@ -30048,6 +30120,8 @@ components:
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
readOnly: true
user_path_template:
type: string
pre_authentication_flow:
type: string
format: uuid
@ -30138,6 +30212,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
pre_authentication_flow:
type: string
format: uuid
@ -30484,6 +30561,8 @@ components:
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
readOnly: true
user_path_template:
type: string
required:
- component
- managed
@ -30526,6 +30605,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated
or a new user enrolled.
user_path_template:
type: string
minLength: 1
required:
- name
- slug
@ -31107,6 +31189,8 @@ components:
uid:
type: string
readOnly: true
path:
type: string
required:
- avatar
- groups
@ -31369,6 +31453,16 @@ components:
minLength: 1
required:
- password
UserPath:
type: object
properties:
paths:
type: array
items:
type: string
readOnly: true
required:
- paths
UserRequest:
type: object
description: User Serializer
@ -31402,6 +31496,9 @@ components:
attributes:
type: object
additionalProperties: {}
path:
type: string
minLength: 1
required:
- groups
- name
@ -31578,6 +31675,8 @@ components:
format: uuid
nullable: true
description: Optionally add newly created users to this group.
user_path_template:
type: string
required:
- component
- meta_model_name
@ -31604,6 +31703,8 @@ components:
format: uuid
nullable: true
description: Optionally add newly created users to this group.
user_path_template:
type: string
required:
- name
ValidationError:

View File

@ -1,4 +1,4 @@
import { CoreApi, SessionUser } from "@goauthentik/api";
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
import { activateLocale } from "../interfaces/locale";
import { DEFAULT_CONFIG } from "./Config";
@ -21,7 +21,7 @@ export function me(): Promise<SessionUser> {
activateLocale(locale);
}
return user;
}).catch((ex) => {
}).catch((ex: ResponseError) => {
const defaultUser: SessionUser = {
user: {
pk: -1,

View File

@ -277,6 +277,12 @@ html > form > input {
.pf-c-select__menu-item:hover {
--pf-c-select__menu-item--hover--BackgroundColor: var(--ak-dark-background-lighter);
}
.pf-c-select__menu-wrapper:focus-within,
.pf-c-select__menu-wrapper.pf-m-focus,
.pf-c-select__menu-item:focus,
.pf-c-select__menu-item.pf-m-focus {
--pf-c-select__menu-item--focus--BackgroundColor: var(--ak-dark-background-light-ish);
}
.pf-c-button.pf-m-plain:hover {
color: var(--ak-dark-foreground);
}
@ -395,6 +401,14 @@ html > form > input {
.pf-c-wizard__nav-link::before {
--pf-c-wizard__nav-link--before--BackgroundColor: transparent;
}
/* tree view */
.pf-c-tree-view__node:focus {
--pf-c-tree-view__node--focus--BackgroundColor: var(--ak-dark-background-light-ish);
}
.pf-c-tree-view__content:hover,
.pf-c-tree-view__content:focus-within {
--pf-c-tree-view__node--hover--BackgroundColor: var(--ak-dark-background-light-ish);
}
}
.pf-c-data-list__item {

View File

@ -0,0 +1,206 @@
import { t } from "@lingui/macro";
import { CSSResult, LitElement, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import AKGlobal from "../authentik.css";
import PFTreeView from "@patternfly/patternfly/components/TreeView/tree-view.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_REFRESH } from "../constants";
import { setURLParams } from "./router/RouteMatch";
export interface TreeViewItem {
id: string;
label: string;
childItems: TreeViewItem[];
parent?: TreeViewItem;
level: number;
}
@customElement("ak-treeview-node")
export class TreeViewNode extends LitElement {
@property({ attribute: false })
item?: TreeViewItem;
@property({ type: Boolean })
open = false;
@property({ attribute: false })
host?: TreeView;
@property()
path = "";
@property()
separator = "";
get openable(): boolean {
return (this.item?.childItems || []).length > 0;
}
get fullPath(): string {
const pathItems = [];
let item = this.item;
while (item) {
pathItems.push(item.id);
item = item.parent;
}
return pathItems.reverse().join(this.separator);
}
protected createRenderRoot(): Element {
return this;
}
firstUpdated(): void {
const pathSegments = this.path.split(this.separator);
const level = this.item?.level || 0;
// Ignore the last item as that shouldn't be expanded
pathSegments.pop();
if (pathSegments[level] == this.item?.id) {
this.open = true;
}
if (this.path === this.fullPath && this.host !== undefined) {
this.host.activeNode = this;
}
}
render(): TemplateResult {
const shouldRenderChildren = (this.item?.childItems || []).length > 0 && this.open;
return html`
<li
class="pf-c-tree-view__list-item ${this.open ? "pf-m-expanded" : ""}"
role="treeitem"
tabindex="0"
>
<div class="pf-c-tree-view__content">
<button
class="pf-c-tree-view__node ${this.host?.activeNode === this
? "pf-m-current"
: ""}"
@click=${() => {
if (this.host) {
this.host.activeNode = this;
}
setURLParams({ path: this.fullPath });
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
}}
>
<div class="pf-c-tree-view__node-container">
${this.openable
? html` <button
class="pf-c-tree-view__node-toggle"
@click=${(e: Event) => {
if (this.openable) {
this.open = !this.open;
e.stopPropagation();
}
}}
>
<span class="pf-c-tree-view__node-toggle-icon">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</span>
</button>`
: html``}
<span class="pf-c-tree-view__node-icon">
<i
class="fas ${this.open ? "fa-folder-open" : "fa-folder"}"
aria-hidden="true"
></i>
</span>
<span class="pf-c-tree-view__node-text">${this.item?.label}</span>
</div>
</button>
</div>
<ul class="pf-c-tree-view__list" role="group" ?hidden=${!shouldRenderChildren}>
${this.item?.childItems.map((item) => {
return html`<ak-treeview-node
.item=${item}
path=${this.path}
separator=${this.separator}
.host=${this.host}
></ak-treeview-node>`;
})}
</ul>
</li>
`;
}
}
@customElement("ak-treeview")
export class TreeView extends LitElement {
static get styles(): CSSResult[] {
return [PFBase, PFTreeView, AKGlobal];
}
@property({ type: Array })
items: string[] = [];
@property()
path = "";
@state()
activeNode?: TreeViewNode;
separator = "/";
createNode(path: string[], tree: TreeViewItem[], level: number): TreeViewItem {
const id = path.shift();
const idx = tree.findIndex((e: TreeViewItem) => {
return e.id == id;
});
if (idx < 0) {
const item: TreeViewItem = {
id: id || "",
label: id || "",
childItems: [],
level: level,
};
tree.push(item);
if (path.length !== 0) {
const child = this.createNode(path, tree[tree.length - 1].childItems, level + 1);
child.parent = item;
}
return item;
} else {
return this.createNode(path, tree[idx].childItems, level + 1);
}
}
parse(data: string[]): TreeViewItem[] {
const tree: TreeViewItem[] = [];
for (let i = 0; i < data.length; i++) {
const path: string = data[i];
const split: string[] = path.split(this.separator);
this.createNode(split, tree, 0);
}
return tree;
}
render(): TemplateResult {
const result = this.parse(this.items);
return html`<div class="pf-c-tree-view pf-m-guides">
<ul class="pf-c-tree-view__list" role="tree">
<!-- @ts-ignore -->
<ak-treeview-node
.item=${{
id: "",
label: t`Root`,
childItems: result,
level: -1,
} as TreeViewItem}
path=${this.path}
?open=${true}
separator=${this.separator}
.host=${this}
></ak-treeview-node>
</ul>
</div>`;
}
}

View File

@ -1,7 +1,7 @@
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { CoreApi } from "@goauthentik/api";
import { CoreApi, ResponseError } from "@goauthentik/api";
import { DEFAULT_CONFIG } from "../../api/Config";
import { ERROR_CLASS, SECONDARY_CLASS, SUCCESS_CLASS } from "../../constants";
@ -37,15 +37,15 @@ export class TokenCopyButton extends ActionButton {
this.buttonClass = SUCCESS_CLASS;
return token.key;
})
.catch((err: Error | Response | undefined) => {
.catch((err: Error | ResponseError | undefined) => {
this.buttonClass = ERROR_CLASS;
if (err instanceof Error) {
if (!(err instanceof ResponseError)) {
setTimeout(() => {
this.buttonClass = SECONDARY_CLASS;
}, 1500);
throw err;
}
return err?.json().then((errResp) => {
return err.response.json().then((errResp) => {
setTimeout(() => {
this.buttonClass = SECONDARY_CLASS;
}, 1500);
@ -92,15 +92,15 @@ export class TokenCopyButton extends ActionButton {
this.setDone(SUCCESS_CLASS);
});
})
.catch((err: Response | Error) => {
if (err instanceof Error) {
.catch((err: ResponseError | Error) => {
if (!(err instanceof ResponseError)) {
showMessage({
level: MessageLevel.error,
message: err.message,
});
return;
}
return err?.json().then((errResp) => {
return err.response.json().then((errResp) => {
this.setDone(ERROR_CLASS);
throw new Error(errResp["detail"]);
});

View File

@ -14,7 +14,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ValidationError } from "@goauthentik/api";
import { ResponseError, ValidationError } from "@goauthentik/api";
import { EVENT_REFRESH } from "../../constants";
import { showMessage } from "../../elements/messages/MessageContainer";
@ -209,13 +209,13 @@ export class Form<T> extends LitElement {
);
return r;
})
.catch(async (ex: Response | Error) => {
if (ex instanceof Error) {
.catch(async (ex: Error | ResponseError) => {
if (!(ex instanceof ResponseError)) {
throw ex;
}
let msg = ex.statusText;
if (ex.status > 399 && ex.status < 500) {
const errorMessage: ValidationError = await ex.json();
let msg = ex.response.statusText;
if (ex.response.status > 399 && ex.response.status < 500) {
const errorMessage: ValidationError = await ex.response.json();
if (!errorMessage) return errorMessage;
if (errorMessage instanceof Error) {
throw errorMessage;

View File

@ -22,6 +22,7 @@ import {
FlowsApi,
LayoutEnum,
RedirectChallenge,
ResponseError,
ShellChallenge,
} from "@goauthentik/api";
@ -193,7 +194,7 @@ export class FlowExecutor extends LitElement implements StageHost {
}
return true;
})
.catch((e: Error | Response) => {
.catch((e: Error | ResponseError) => {
this.errorMessage(e);
return false;
})
@ -226,7 +227,7 @@ export class FlowExecutor extends LitElement implements StageHost {
this.setBackground(this.challenge.flowInfo.background);
}
})
.catch((e: Error | Response) => {
.catch((e: Error | ResponseError) => {
// Catch JSON or Update errors
this.errorMessage(e);
})
@ -235,9 +236,11 @@ export class FlowExecutor extends LitElement implements StageHost {
});
}
async errorMessage(error: Error | Response): Promise<void> {
async errorMessage(error: Error | ResponseError): Promise<void> {
let body = "";
if (error instanceof Error) {
if (error instanceof ResponseError) {
body = await error.response.text();
} else if (error instanceof Error) {
body = error.message;
}
this.challenge = {

View File

@ -15,6 +15,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
PlexAuthenticationChallenge,
PlexAuthenticationChallengeResponseRequest,
ResponseError,
} from "@goauthentik/api";
import { SourcesApi } from "@goauthentik/api";
@ -48,8 +49,8 @@ export class PlexLoginInit extends BaseStage<
.then((r) => {
window.location.assign(r.to);
})
.catch((r: Response) => {
r.json().then((body: { detail: string }) => {
.catch((r: ResponseError) => {
r.response.json().then((body: { detail: string }) => {
showMessage({
level: MessageLevel.error,
message: body.detail,

View File

@ -12,7 +12,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Flow, FlowsApi } from "@goauthentik/api";
import { Flow, FlowsApi, ResponseError } from "@goauthentik/api";
import { AndNext, DEFAULT_CONFIG } from "../../api/Config";
import "../../elements/PageHeader";
@ -164,10 +164,13 @@ export class FlowViewPage extends LitElement {
)}`;
window.open(finalURL, "_blank");
})
.catch((exc: Response) => {
.catch((exc: ResponseError) => {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(exc.url, "_blank");
window.open(
exc.response.url,
"_blank",
);
});
}}
>

View File

@ -209,7 +209,7 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "username",
ordering: "name",
};
if (query !== undefined) {
args.search = query;

View File

@ -7,7 +7,9 @@ import { until } from "lit/directives/until.js";
import {
CoreApi,
CoreGroupsListRequest,
CryptoApi,
Group,
LDAPSource,
LDAPSourceRequest,
PropertymappingsApi,
@ -15,6 +17,7 @@ import {
} from "@goauthentik/api";
import { DEFAULT_CONFIG } from "../../../api/Config";
import "../../../elements/SearchSelect";
import "../../../elements/forms/FormGroup";
import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm";
@ -301,31 +304,49 @@ export class LDAPSourceForm extends ModelForm<LDAPSource, string> {
<span slot="header"> ${t`Additional settings`} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`Group`} name="syncParentGroup">
<select class="pf-c-form-control">
<option
value=""
?selected=${this.instance?.syncParentGroup === undefined}
>
---------
</option>
${until(
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => {
return groups.results.map((group) => {
return html`<option
value=${ifDefined(group.pk)}
?selected=${this.instance?.syncParentGroup === group.pk}
>
${group.name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<!-- @ts-ignore -->
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | undefined): string | undefined => {
return group ? group.pk : undefined;
}}
.selected=${(group: Group): boolean => {
return group.pk === this.instance?.syncParentGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${t`Parent group for all the groups imported from LDAP.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Addition User DN`}
name="additionalUserDn"

View File

@ -268,6 +268,19 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
</option>
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span>

View File

@ -215,6 +215,19 @@ export class PlexSourceForm extends ModelForm<PlexSource, string> {
</option>
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span>

View File

@ -232,6 +232,19 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> {
</option>
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Delete temporary users after`}
?required=${true}

View File

@ -3,11 +3,11 @@ import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import { CoreApi, StagesApi, UserWriteStage } from "@goauthentik/api";
import { CoreApi, CoreGroupsListRequest, Group, StagesApi, UserWriteStage } from "@goauthentik/api";
import { DEFAULT_CONFIG } from "../../../api/Config";
import "../../../elements/SearchSelect";
import "../../../elements/forms/FormGroup";
import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm";
@ -74,29 +74,44 @@ export class UserWriteStageForm extends ModelForm<UserWriteStage, string> {
${t`Mark newly created users as inactive.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="userPathTemplate">
<input
type="text"
value="${first(this.instance?.userPathTemplate, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Path new users will be created under.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Group`} name="createUsersGroup">
<select class="pf-c-form-control">
<option
value=""
?selected=${this.instance?.createUsersGroup === undefined}
>
---------
</option>
${until(
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => {
return groups.results.map((group) => {
return html`<option
value=${ifDefined(group.pk)}
?selected=${this.instance?.createUsersGroup ===
group.pk}
>
${group.name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<!-- @ts-ignore -->
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | undefined): string | undefined => {
return group ? group.pk : undefined;
}}
.selected=${(group: Group): boolean => {
return group.pk === this.instance?.createUsersGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${t`Newly created users are added to this group, if a group is selected.`}
</p>

View File

@ -7,7 +7,7 @@ import { until } from "lit/directives/until.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CapabilitiesEnum, CoreApi, User } from "@goauthentik/api";
import { CapabilitiesEnum, CoreApi, ResponseError, User } from "@goauthentik/api";
import { AKResponse } from "../../api/Client";
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
@ -244,8 +244,8 @@ export class RelatedUserList extends Table<User> {
description: rec.link,
});
})
.catch((ex: Response) => {
ex.json().then(() => {
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: t`No recovery flow is configured.`,

View File

@ -69,6 +69,14 @@ export class UserForm extends ModelForm<User, number> {
${t`User's primary identifier. 150 characters or fewer.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Path`} ?required=${true} name="path">
<input
type="text"
value="${first(this.instance?.path, "users")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Name`} name="name">
<input
type="text"

View File

@ -4,10 +4,12 @@ import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import AKGlobal from "../../authentik.css";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CapabilitiesEnum, CoreApi, User } from "@goauthentik/api";
import { CapabilitiesEnum, CoreApi, ResponseError, User } from "@goauthentik/api";
import { AKResponse } from "../../api/Client";
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
@ -15,12 +17,13 @@ import { me } from "../../api/Users";
import { uiConfig } from "../../common/config";
import { PFColor } from "../../elements/Label";
import { PFSize } from "../../elements/Spinner";
import "../../elements/TreeView";
import "../../elements/buttons/ActionButton";
import "../../elements/forms/DeleteBulkForm";
import "../../elements/forms/ModalForm";
import { MessageLevel } from "../../elements/messages/Message";
import { showMessage } from "../../elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "../../elements/router/RouteMatch";
import { getURLParam } from "../../elements/router/RouteMatch";
import { TableColumn } from "../../elements/table/Table";
import { TablePage } from "../../elements/table/TablePage";
import { first } from "../../utils";
@ -51,11 +54,11 @@ export class UserListPage extends TablePage<User> {
@property()
order = "last_login";
@property({ type: Boolean })
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
@property()
path = getURLParam<string>("path", "/");
static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList, PFAlert);
return super.styles.concat(PFDescriptionList, PFCard, PFAlert, AKGlobal);
}
async apiEndpoint(page: number): Promise<AKResponse<User>> {
@ -64,11 +67,7 @@ export class UserListPage extends TablePage<User> {
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
attributes: this.hideServiceAccounts
? JSON.stringify({
"goauthentik.io/user/service-account__isnull": true,
})
: undefined,
pathStartswith: getURLParam("path", ""),
});
}
@ -251,8 +250,8 @@ export class UserListPage extends TablePage<User> {
description: rec.link,
});
})
.catch((ex: Response) => {
ex.json().then(() => {
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: t`No recovery flow is configured.`,
@ -320,33 +319,25 @@ export class UserListPage extends TablePage<User> {
`;
}
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<div class="pf-c-check">
<input
class="pf-c-check__input"
type="checkbox"
id="hide-service-accounts"
name="hide-service-accounts"
?checked=${this.hideServiceAccounts}
@change=${() => {
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
updateURLParams({
hideServiceAccounts: this.hideServiceAccounts,
});
}}
/>
<label class="pf-c-check__label" for="hide-service-accounts">
${t`Hide service-accounts`}
</label>
</div>
</div>
renderSidebarBefore(): TemplateResult {
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card">
<div class="pf-c-card__title">${t`User folders`}</div>
<div class="pf-c-card__body">
${until(
new CoreApi(DEFAULT_CONFIG)
.coreUsersPathsRetrieve({
search: this.search,
})
.then((paths) => {
return html`<ak-treeview
.items=${paths.paths}
path=${this.path}
></ak-treeview>`;
}),
)}
</div>
</div>`;
</div>
</div>`;
}
}

View File

@ -18,6 +18,7 @@ import {
FlowChallengeResponseRequest,
FlowsApi,
RedirectChallenge,
ResponseError,
ShellChallenge,
} from "@goauthentik/api";
@ -80,7 +81,7 @@ export class UserSettingsFlowExecutor extends LitElement implements StageHost {
}
return true;
})
.catch((e: Error | Response) => {
.catch((e: Error | ResponseError) => {
this.errorMessage(e);
return false;
})

View File

@ -0,0 +1,36 @@
---
title: Release 2022.7
slug: "2022.7"
---
## Breaking changes
- Removal of verification certificates for Machine-to-Machine authentication in OAuth 2 Provider
Instead, create an OAuth Source with the certificate configured as JWKS Data, and enable the source in the provider.
## New features
- User paths
To better organize users, they can now be assigned a path. This allows for organization of users based on sources they enrolled with/got imported from, organizational structure or any other structure.
Sources now have a path template to specify which path users created by it should be assigned. Additionally, you can set the path in the user_write stage in any flow, and it can be dynamically overwritten within a flow's context.
## Upgrading
This release does not introduce any new requirements.
### docker-compose
Download the docker-compose file for 2022.7 from [here](https://goauthentik.io/version/2022.7/docker-compose.yml). Afterwards, simply run `docker-compose up -d`.
### Kubernetes
Update your values to use the new images:
```yaml
image:
repository: ghcr.io/goauthentik/server
tag: 2022.7.1
```

View File

@ -2,6 +2,14 @@
title: User
---
## Path
:::info
Requires authentik 2022.7
:::
Paths can be used to organize users into folders depending on which source created them or organizational structure. Paths may not start or end with a slash, but they can contain any other character as path segments. The paths are currently purely used for organization, it does not affect their permissions, group memberships, or anything else.
## Attributes
### `goauthentik.io/user/token-expires`: