Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core: user paths #3085

Merged
merged 10 commits into from
Jun 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions authentik/core/api/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Meta:
"policy_engine_mode",
"user_matching_mode",
"managed",
"user_path_template",
]


Expand Down
48 changes: 47 additions & 1 deletion authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)
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 (
Expand All @@ -50,6 +50,7 @@
from authentik.core.models import (
USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
Group,
Token,
TokenIntents,
Expand Down Expand Up @@ -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
Expand All @@ -93,6 +103,7 @@ class Meta:
"avatar",
"attributes",
"uid",
"path",
]
extra_kwargs = {
"name": {"allow_blank": True},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -314,6 +330,7 @@ def service_account(self, request: Request) -> Response:
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(
Expand Down Expand Up @@ -464,3 +481,32 @@ def filter_queryset(self, queryset):
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)
)
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@


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(
Expand All @@ -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()


Expand Down
8 changes: 4 additions & 4 deletions authentik/core/migrations/0003_default_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@


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(
Expand All @@ -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()


Expand Down
23 changes: 23 additions & 0 deletions authentik/core/migrations/0021_source_user_path_user_path.py
Original file line number Diff line number Diff line change
@@ -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"),
),
]
22 changes: 22 additions & 0 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
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")

Expand Down Expand Up @@ -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")
Expand All @@ -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"""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"""
Expand Down
2 changes: 2 additions & 0 deletions authentik/core/sources/flow_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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):
Expand Down Expand Up @@ -291,5 +292,6 @@ def handle_enroll(
connection,
**{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
},
)
64 changes: 63 additions & 1 deletion authentik/core/tests/test_users_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

Expand Down Expand Up @@ -149,3 +149,65 @@ def test_service_account_invalid(self):
},
)
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."]}
)
4 changes: 4 additions & 0 deletions authentik/outposts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from authentik.core.models import (
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
USER_ATTRIBUTE_SA,
USER_PATH_SYSTEM_PREFIX,
Provider,
Token,
TokenIntents,
Expand All @@ -39,6 +40,8 @@
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"""
Expand Down Expand Up @@ -339,6 +342,7 @@ def user(self) -> User:
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)
Expand Down
4 changes: 3 additions & 1 deletion authentik/sources/ldap/sync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ def _flatten(self, value: Any) -> Any:

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."""
Expand Down
1 change: 1 addition & 0 deletions authentik/sources/saml/processors/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse:
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()
Expand Down
6 changes: 5 additions & 1 deletion authentik/stages/user_write/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down