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
37 changed files with 771 additions and 127 deletions

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."]}
)