diff --git a/Makefile b/Makefile index 1807681ca..9066c92fa 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index fc5309427..209a3eda5 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "policy_engine_mode", "user_matching_mode", "managed", + "user_path_template", ] diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 921f6e0f8..f6862908b 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -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) + ) + } + ) diff --git a/authentik/core/migrations/0002_auto_20200523_1133_squashed_0011_provider_name_temp.py b/authentik/core/migrations/0002_auto_20200523_1133_squashed_0011_provider_name_temp.py index 1e4fce7fa..242e0c141 100644 --- a/authentik/core/migrations/0002_auto_20200523_1133_squashed_0011_provider_name_temp.py +++ b/authentik/core/migrations/0002_auto_20200523_1133_squashed_0011_provider_name_temp.py @@ -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() diff --git a/authentik/core/migrations/0003_default_user.py b/authentik/core/migrations/0003_default_user.py index 871aa7161..6549a1ded 100644 --- a/authentik/core/migrations/0003_default_user.py +++ b/authentik/core/migrations/0003_default_user.py @@ -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() diff --git a/authentik/core/migrations/0021_source_user_path_user_path.py b/authentik/core/migrations/0021_source_user_path_user_path.py new file mode 100644 index 000000000..9409962c8 --- /dev/null +++ b/authentik/core/migrations/0021_source_user_path_user_path.py @@ -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"), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 573b9e610..9871dd325 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -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""" diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 95ebd62bb..e0a6b4ff2 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -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(), }, ) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 6cddd3043..210b0de62 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -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."]} + ) diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 012cf35e7..f7024ac13 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -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) diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 16b812a50..f17c0066e 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -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.""" diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py index 08aa6c655..788acb4cd 100644 --- a/authentik/sources/saml/processors/response.py +++ b/authentik/sources/saml/processors/response.py @@ -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() diff --git a/authentik/stages/user_write/api.py b/authentik/stages/user_write/api.py index c1a1baf56..c944e2dae 100644 --- a/authentik/stages/user_write/api.py +++ b/authentik/stages/user_write/api.py @@ -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): diff --git a/authentik/stages/user_write/migrations/0005_userwritestage_user_path_template.py b/authentik/stages/user_write/migrations/0005_userwritestage_user_path_template.py new file mode 100644 index 000000000..acf691594 --- /dev/null +++ b/authentik/stages/user_write/migrations/0005_userwritestage_user_path_template.py @@ -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), + ), + ] diff --git a/authentik/stages/user_write/models.py b/authentik/stages/user_write/models.py index 9f0d16af5..b967a82a4 100644 --- a/authentik/stages/user_write/models.py +++ b/authentik/stages/user_write/models.py @@ -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 diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py index 6b5fd1af2..89fb20749 100644 --- a/authentik/stages/user_write/stage.py +++ b/authentik/stages/user_write/stage.py @@ -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( diff --git a/schema.yml b/schema.yml index 3b84e872f..8333faf8d 100644 --- a/schema.yml +++ b/schema.yml @@ -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: diff --git a/web/src/api/Users.ts b/web/src/api/Users.ts index 6dfa34d26..127a93f54 100644 --- a/web/src/api/Users.ts +++ b/web/src/api/Users.ts @@ -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 { activateLocale(locale); } return user; - }).catch((ex) => { + }).catch((ex: ResponseError) => { const defaultUser: SessionUser = { user: { pk: -1, diff --git a/web/src/authentik.css b/web/src/authentik.css index dca5b6a9b..881e2f5ed 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -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 { diff --git a/web/src/elements/TreeView.ts b/web/src/elements/TreeView.ts new file mode 100644 index 000000000..1604e0783 --- /dev/null +++ b/web/src/elements/TreeView.ts @@ -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` +
  • +
    + ` + : html``} + + + + ${this.item?.label} +
    + + + +
  • + `; + } +} + +@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`
    + +
    `; + } +} diff --git a/web/src/elements/buttons/TokenCopyButton.ts b/web/src/elements/buttons/TokenCopyButton.ts index 3c5b35032..f733affce 100644 --- a/web/src/elements/buttons/TokenCopyButton.ts +++ b/web/src/elements/buttons/TokenCopyButton.ts @@ -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"]); }); diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index 61d5ea335..6e0583bda 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -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 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; diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index 8428f807b..578a275f6 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -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 { + async errorMessage(error: Error | ResponseError): Promise { 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 = { diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index 4196e67a1..3cffa9b4a 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -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, diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts index 1ca6d0cdb..450dca022 100644 --- a/web/src/pages/flows/FlowViewPage.ts +++ b/web/src/pages/flows/FlowViewPage.ts @@ -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", + ); }); }} > diff --git a/web/src/pages/policies/PolicyBindingForm.ts b/web/src/pages/policies/PolicyBindingForm.ts index 52dea9ddb..356202413 100644 --- a/web/src/pages/policies/PolicyBindingForm.ts +++ b/web/src/pages/policies/PolicyBindingForm.ts @@ -209,7 +209,7 @@ export class PolicyBindingForm extends ModelForm { => { const args: CoreGroupsListRequest = { - ordering: "username", + ordering: "name", }; if (query !== undefined) { args.search = query; diff --git a/web/src/pages/sources/ldap/LDAPSourceForm.ts b/web/src/pages/sources/ldap/LDAPSourceForm.ts index 771313257..7a3bbef14 100644 --- a/web/src/pages/sources/ldap/LDAPSourceForm.ts +++ b/web/src/pages/sources/ldap/LDAPSourceForm.ts @@ -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 { ${t`Additional settings`}
    - + + => { + 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} + > +

    ${t`Parent group for all the groups imported from LDAP.`}

    + + +

    + ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`} +

    +
    { + + +

    + ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`} +

    +
    ${t`Protocol settings`} diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts index 7ee176d43..7b1293f8d 100644 --- a/web/src/pages/sources/plex/PlexSourceForm.ts +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -215,6 +215,19 @@ export class PlexSourceForm extends ModelForm { + + +

    + ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`} +

    +
    ${t`Protocol settings`} diff --git a/web/src/pages/sources/saml/SAMLSourceForm.ts b/web/src/pages/sources/saml/SAMLSourceForm.ts index 4ce2c40a3..e9630a971 100644 --- a/web/src/pages/sources/saml/SAMLSourceForm.ts +++ b/web/src/pages/sources/saml/SAMLSourceForm.ts @@ -232,6 +232,19 @@ export class SAMLSourceForm extends ModelForm { + + +

    + ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`} +

    +
    { ${t`Mark newly created users as inactive.`}

    + + +

    + ${t`Path new users will be created under.`} +

    +
    - + + => { + 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} + > +

    ${t`Newly created users are added to this group, if a group is selected.`}

    diff --git a/web/src/pages/users/RelatedUserList.ts b/web/src/pages/users/RelatedUserList.ts index 76d9fd4cb..fd5bcaba6 100644 --- a/web/src/pages/users/RelatedUserList.ts +++ b/web/src/pages/users/RelatedUserList.ts @@ -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 { 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.`, diff --git a/web/src/pages/users/UserForm.ts b/web/src/pages/users/UserForm.ts index 822c50eac..e364a795e 100644 --- a/web/src/pages/users/UserForm.ts +++ b/web/src/pages/users/UserForm.ts @@ -69,6 +69,14 @@ export class UserForm extends ModelForm { ${t`User's primary identifier. 150 characters or fewer.`}

    + + + { @property() order = "last_login"; - @property({ type: Boolean }) - hideServiceAccounts = getURLParam("hideServiceAccounts", true); + @property() + path = getURLParam("path", "/"); static get styles(): CSSResult[] { - return super.styles.concat(PFDescriptionList, PFAlert); + return super.styles.concat(PFDescriptionList, PFCard, PFAlert, AKGlobal); } async apiEndpoint(page: number): Promise> { @@ -64,11 +67,7 @@ export class UserListPage extends TablePage { 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 { 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 { `; } - renderToolbarAfter(): TemplateResult { - return html`  -
    -
    -
    -
    - { - this.hideServiceAccounts = !this.hideServiceAccounts; - this.page = 1; - this.fetch(); - updateURLParams({ - hideServiceAccounts: this.hideServiceAccounts, - }); - }} - /> - -
    -
    + renderSidebarBefore(): TemplateResult { + return html`
    +
    +
    ${t`User folders`}
    +
    + ${until( + new CoreApi(DEFAULT_CONFIG) + .coreUsersPathsRetrieve({ + search: this.search, + }) + .then((paths) => { + return html``; + }), + )}
    -
    `; +
    +
    `; } } diff --git a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts index 790bb7ac6..78e8cb2c0 100644 --- a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts +++ b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts @@ -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; }) diff --git a/website/docs/releases/v2022.7.md b/website/docs/releases/v2022.7.md new file mode 100644 index 000000000..a2119029d --- /dev/null +++ b/website/docs/releases/v2022.7.md @@ -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 +``` diff --git a/website/docs/user-group/user.md b/website/docs/user-group/user.md index ac2287ad5..60069a8f1 100644 --- a/website/docs/user-group/user.md +++ b/website/docs/user-group/user.md @@ -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`: