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:
@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"policy_engine_mode",
|
||||
"user_matching_mode",
|
||||
"managed",
|
||||
"user_path_template",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
@ -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"""
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
@ -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."]}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user