mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
34 Commits
sdko/stage
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4ecf929f | ||
|
|
8336556a6f | ||
|
|
709aad1d3b | ||
|
|
fb7ab4937c | ||
|
|
5df1726d80 | ||
|
|
9fdb568843 | ||
|
|
8e76f56f89 | ||
|
|
05d3791577 | ||
|
|
d00dd7eb90 | ||
|
|
8d2e404017 | ||
|
|
95eb2af25e | ||
|
|
cbc00a501b | ||
|
|
480645d897 | ||
|
|
997c767c95 | ||
|
|
5a54e1dc9a | ||
|
|
49b1952566 | ||
|
|
e73edc2fce | ||
|
|
409652e874 | ||
|
|
1d3fb6431f | ||
|
|
76cfada60f | ||
|
|
ac45f80551 | ||
|
|
5ea85f086a | ||
|
|
e3f657746c | ||
|
|
001b56e2cc | ||
|
|
ecbfd2f0de | ||
|
|
45753397e1 | ||
|
|
dc6fe1dafe | ||
|
|
d5e8f2f416 | ||
|
|
d73af5a2b4 | ||
|
|
7042f2bba8 | ||
|
|
efeb260fa8 | ||
|
|
29e90092ea | ||
|
|
0abe865023 | ||
|
|
220c65a41a |
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@@ -49,8 +49,12 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.12.0-rc1"
|
||||
VERSION = "2025.12.0-rc2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
||||
|
||||
def get_version_latest(self, _) -> str:
|
||||
"""Get latest version from cache"""
|
||||
if get_current_tenant().schema_name == get_public_schema_name():
|
||||
if get_current_tenant().schema_name != get_public_schema_name():
|
||||
return authentik_version()
|
||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||
if not version_in_cache: # pragma: no cover
|
||||
|
||||
@@ -240,7 +240,9 @@ class FileUsedByView(APIView):
|
||||
for field in fields:
|
||||
q |= Q(**{field: params.get("name")})
|
||||
|
||||
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
|
||||
objs = get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", model.objects.all()
|
||||
)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
|
||||
@ManagedAppConfig.reconcile_global
|
||||
def check_for_media_mount(self):
|
||||
if settings.TEST:
|
||||
return
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
|
||||
and Path("/media").exists()
|
||||
):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="/media has been moved to /data/media. "
|
||||
"Check the release notes for migration steps.",
|
||||
).save()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Test file service layer"""
|
||||
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
|
||||
from authentik.admin.files.tests.utils import (
|
||||
FileTestFileBackendMixin,
|
||||
FileTestS3BackendMixin,
|
||||
s3_test_server_available,
|
||||
)
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
@@ -81,6 +87,7 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
self.assertEqual(result, "http://example.com/files/media/public/test.png")
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
|
||||
@CONFIG.patch("storage.media.s3.secure_urls", False)
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import shutil
|
||||
import socket
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG, UNSET
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
S3_TEST_ENDPOINT = "http://localhost:8020"
|
||||
|
||||
|
||||
def s3_test_server_available() -> bool:
|
||||
"""Check if the S3 test server is reachable."""
|
||||
|
||||
parsed = urlparse(S3_TEST_ENDPOINT)
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class FileTestFileBackendMixin:
|
||||
def setUp(self):
|
||||
@@ -57,7 +72,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
|
||||
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
@@ -70,7 +85,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
|
||||
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
|
||||
@@ -15,7 +15,9 @@ class Pagination(pagination.PageNumberPagination):
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
page_size = super().get_page_size(request)
|
||||
if page_size is not None:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
|
||||
@@ -180,10 +180,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, applications: QuerySet[Application]
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in applications:
|
||||
for app in paginated_apps:
|
||||
if app.get_launch_url():
|
||||
applications.append(app)
|
||||
return applications
|
||||
|
||||
@@ -33,6 +33,16 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class PartialUserSerializer(ModelSerializer):
|
||||
"""Partial User Serializer, does not include child relations."""
|
||||
@@ -42,16 +52,7 @@ class PartialUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
||||
|
||||
|
||||
class RelatedGroupSerializer(ModelSerializer):
|
||||
@@ -262,7 +263,14 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
base_qs = Group.objects.all().prefetch_related("roles")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
base_qs = base_qs.prefetch_related("users")
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
# time
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch(
|
||||
"users",
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
|
||||
@@ -18,10 +18,9 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
|
||||
|
||||
def get_role_for_user_id(user_id: int) -> Role:
|
||||
name = f"ak-managed-role--user-{user_id}"
|
||||
name = f"ak-migrated-role--user-{user_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.users.add(user_id)
|
||||
@@ -32,11 +31,10 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
if not role:
|
||||
# Every django group should already have a role, so this should never happen.
|
||||
# But let's be nice.
|
||||
name = f"ak-managed-role--group-{group_id}"
|
||||
name = f"ak-migrated-role--group-{group_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.group_id = group_id
|
||||
|
||||
@@ -86,7 +86,7 @@ class OutpostConfig:
|
||||
class OutpostModel(Model):
|
||||
"""Base model for providers that need more objects than just themselves"""
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
"""Return a list of all required objects"""
|
||||
return [self]
|
||||
|
||||
@@ -332,41 +332,35 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
"""Create per-object and global permissions for outpost service-account"""
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
model_or_perm: models.Model
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
try:
|
||||
user.assign_perms_to_managed_role(code_name, model_or_perm)
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
try:
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
else:
|
||||
app_label, perm = model_or_perm.split(".")
|
||||
permission = Permission.objects.filter(
|
||||
codename=perm,
|
||||
content_type__app_label=app_label,
|
||||
)
|
||||
if not permission.exists():
|
||||
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
|
||||
continue
|
||||
user.assign_perms_to_managed_role(permission.first())
|
||||
user.assign_perms_to_managed_role(code_name, model_or_perm)
|
||||
elif isinstance(model_or_perm, tuple):
|
||||
perm, obj = model_or_perm
|
||||
user.assign_perms_to_managed_role(perm, obj)
|
||||
else:
|
||||
user.assign_perms_to_managed_role(model_or_perm)
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
LOGGER.debug(
|
||||
"Updated service account's permissions",
|
||||
obj_perms=user.get_all_obj_perms_on_managed_role(),
|
||||
@@ -431,7 +425,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
Token.objects.filter(identifier=self.token_identifier).delete()
|
||||
return self.token
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
"""Get an iterator of all objects the user needs read access to"""
|
||||
objects: list[models.Model | str] = [
|
||||
self,
|
||||
@@ -445,7 +439,9 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
if self.managed:
|
||||
for brand in Brand.objects.filter(web_certificate__isnull=False):
|
||||
objects.append(brand)
|
||||
objects.append(brand.web_certificate)
|
||||
objects.append(("view_certificatekeypair", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_certificate", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_key", brand.web_certificate))
|
||||
return objects
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -51,10 +51,12 @@ class OutpostTests(TestCase):
|
||||
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
|
||||
"content_type__model"
|
||||
)
|
||||
self.assertEqual(len(permissions), 3)
|
||||
self.assertEqual(len(permissions), 5)
|
||||
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(provider.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[3].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[4].object_pk, str(provider.pk))
|
||||
|
||||
# Remove provider from outpost, user should only have access to outpost
|
||||
outpost.providers.remove(provider)
|
||||
|
||||
@@ -93,11 +93,13 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
|
||||
def __str__(self):
|
||||
return f"LDAP Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
if self.certificate is not None:
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("LDAP Provider")
|
||||
|
||||
@@ -179,11 +179,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
def __str__(self):
|
||||
return f"Proxy Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self]
|
||||
if self.certificate is not None:
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Proxy Provider")
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""proxy provider tests"""
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.models import ClientTypes
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
@@ -127,3 +131,55 @@ class ProxyProviderTests(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
|
||||
def test_sa_fetch(self):
|
||||
"""Test fetching the outpost config as the service account"""
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id())
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
|
||||
def test_sa_perms_cert(self):
|
||||
"""Test permissions to access a configured certificate"""
|
||||
cert = create_test_cert()
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id(), certificate=cert)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
cert_id = body["results"][0]["certificate"]
|
||||
self.assertEqual(cert_id, str(cert.pk))
|
||||
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-certificate",
|
||||
kwargs={
|
||||
"pk": cert_id,
|
||||
},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
# res = self.client.get(
|
||||
# reverse(
|
||||
# "authentik_api:certificatekeypair-view-private-key",
|
||||
# kwargs={
|
||||
# "pk": cert_id,
|
||||
# },
|
||||
# ),
|
||||
# HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
# )
|
||||
# self.assertEqual(res.status_code, 200)
|
||||
|
||||
@@ -64,10 +64,12 @@ class RadiusProvider(OutpostModel, Provider):
|
||||
|
||||
return RadiusProviderSerializer
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self, "authentik_stages_mtls.pass_outpost_certificate"]
|
||||
if self.certificate is not None:
|
||||
required.append(self.certificate)
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.http.request import QueryDict
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import (
|
||||
@@ -47,7 +47,7 @@ class AuthenticatorEmailChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: EmailDevice
|
||||
|
||||
code = IntegerField(required=False)
|
||||
code = CharField(required=False)
|
||||
email = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-email")
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@@ -38,7 +38,7 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: SMSDevice
|
||||
|
||||
code = IntegerField(required=False)
|
||||
code = CharField(required=False)
|
||||
phone_number = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-sms")
|
||||
|
||||
@@ -5,7 +5,7 @@ from urllib.parse import quote
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
@@ -32,10 +32,10 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: TOTPDevice
|
||||
|
||||
code = IntegerField()
|
||||
code = CharField()
|
||||
component = CharField(default="ak-stage-authenticator-totp")
|
||||
|
||||
def validate_code(self, code: int) -> int:
|
||||
def validate_code(self, code: str) -> str:
|
||||
"""Validate totp code"""
|
||||
if not self.device:
|
||||
raise ValidationError(_("Code does not match"))
|
||||
|
||||
@@ -245,7 +245,10 @@ class WorkerStatusMiddleware(Middleware):
|
||||
WorkerStatusMiddleware.keep(status)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
sleep(10)
|
||||
pass
|
||||
try:
|
||||
connections.close_all()
|
||||
except DB_ERRORS:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def keep(status: WorkerStatus):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.12.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.12.0-rc2 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -14510,7 +14510,8 @@
|
||||
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
|
||||
},
|
||||
"webauthn_stage": {
|
||||
"type": "integer",
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Webauthn stage",
|
||||
"description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor."
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc2}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./media:/data/media
|
||||
- ./data:/data
|
||||
- ./custom-templates:/templates
|
||||
worker:
|
||||
command: worker
|
||||
@@ -52,12 +52,12 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc2}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./media:/data/media
|
||||
- ./data:/data
|
||||
- ./certs:/certs
|
||||
- ./custom-templates:/templates
|
||||
volumes:
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.12.0-rc1
|
||||
2025.12.0-rc2
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.12.0-rc1
|
||||
Default: 2025.12.0-rc2
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@goauthentik/eslint-config": "./packages/eslint-config",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -529,3 +529,7 @@ class _PostgresConsumer(Consumer):
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
try:
|
||||
connections.close_all()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.12.0-rc1"
|
||||
version = "2025.12.0-rc2"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
11
schema.yml
11
schema.yml
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.12.0-rc1
|
||||
version: 2025.12.0-rc2
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@@ -33586,7 +33586,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-email
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
email:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -33833,7 +33834,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-sms
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
phone_number:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -34107,7 +34109,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-totp
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- code
|
||||
AuthenticatorTOTPStage:
|
||||
|
||||
6
scripts/generate_docker_compose.py
Normal file → Executable file
6
scripts/generate_docker_compose.py
Normal file → Executable file
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from yaml import safe_dump
|
||||
|
||||
from authentik import authentik_version
|
||||
@@ -42,7 +44,7 @@ base = {
|
||||
"image": authentik_image,
|
||||
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
|
||||
"restart": "unless-stopped",
|
||||
"volumes": ["./media:/data/media", "./custom-templates:/templates"],
|
||||
"volumes": ["./data:/data", "./custom-templates:/templates"],
|
||||
},
|
||||
"worker": {
|
||||
"command": "worker",
|
||||
@@ -62,7 +64,7 @@ base = {
|
||||
"user": "root",
|
||||
"volumes": [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"./media:/data/media",
|
||||
"./data:/data",
|
||||
"./certs:/certs",
|
||||
"./custom-templates:/templates",
|
||||
],
|
||||
|
||||
@@ -23,8 +23,10 @@ from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import (
|
||||
DetachedShadowRootException,
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
@@ -326,18 +328,23 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
|
||||
while attempts < SHADOW_ROOT_RETRIES:
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
return host.shadow_root
|
||||
except NoSuchShadowRootException:
|
||||
except (
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
DetachedShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
):
|
||||
attempts += 1
|
||||
sleep(0.2)
|
||||
# re-find host in case it was re-attached
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
except NoSuchElementException:
|
||||
# loop and retry finding host
|
||||
pass
|
||||
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
inner_html = "<no host>"
|
||||
if host is not None:
|
||||
try:
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
except (DetachedShadowRootException, StaleElementReferenceException):
|
||||
inner_html = "<stale host>"
|
||||
|
||||
raise RuntimeError(
|
||||
f"Failed to obtain shadow root for {selector} after {attempts} attempts. "
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -185,7 +185,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.12.0rc1"
|
||||
version = "2025.12.0rc2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ak-guardian" },
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc2",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -99,6 +99,9 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
const alertMsg = msg(
|
||||
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
|
||||
);
|
||||
const providerFromInstance = this.instance?.provider;
|
||||
const providerValue = providerFromInstance ?? this.provider;
|
||||
const providerPrefilled = !this.instance && this.provider !== undefined;
|
||||
|
||||
return html`
|
||||
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
|
||||
@@ -134,9 +137,10 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
<ak-provider-search-input
|
||||
name="provider"
|
||||
label=${msg("Provider")}
|
||||
value=${ifPresent(this.instance?.provider)}
|
||||
.value=${providerValue}
|
||||
.readOnly=${providerPrefilled}
|
||||
?blankable=${!providerPrefilled}
|
||||
help=${msg("Select a provider that this application should use.")}
|
||||
blankable
|
||||
></ak-provider-search-input>
|
||||
<ak-backchannel-providers-input
|
||||
name="backchannelProviders"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/ap
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const renderElement = (item: Provider) => item.name;
|
||||
const renderValue = (item: Provider | undefined) => item?.pk;
|
||||
@@ -53,6 +54,9 @@ export class AkProviderInput extends AKElement {
|
||||
@property({ type: Number })
|
||||
value?: number;
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
readOnly = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@@ -76,6 +80,8 @@ export class AkProviderInput extends AKElement {
|
||||
};
|
||||
|
||||
render() {
|
||||
const readOnlyValue = this.readOnly && typeof this.value === "number";
|
||||
|
||||
return html` <ak-form-element-horizontal name=${this.name}>
|
||||
${AKLabel(
|
||||
{
|
||||
@@ -86,7 +92,9 @@ export class AkProviderInput extends AKElement {
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
|
||||
${readOnlyValue
|
||||
? html`<input type="hidden" name=${this.name} value=${this.value ?? ""} />`
|
||||
: nothing}
|
||||
<ak-search-select
|
||||
.fieldID=${this.fieldID}
|
||||
.selected=${this.#selected}
|
||||
@@ -94,7 +102,9 @@ export class AkProviderInput extends AKElement {
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
.groupBy=${doGroupBy}
|
||||
?blankable=${!!this.blankable}
|
||||
?blankable=${readOnlyValue ? false : !!this.blankable}
|
||||
?readonly=${this.readOnly}
|
||||
name=${ifDefined(readOnlyValue ? undefined : this.name)}
|
||||
>
|
||||
</ak-search-select>
|
||||
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
|
||||
|
||||
@@ -77,7 +77,11 @@ export class AgentConnectorSetup extends AKElement {
|
||||
<p>${msg("Afterwards, select the enrollment token you want to use:")}</p>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-12-col">
|
||||
<p>${msg("Then download the configuration to deploy the authentik Agent")}</p>
|
||||
<p>
|
||||
${msg(
|
||||
"Next, download the configuration to deploy the authentik Agent via MDM",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-6-col pf-l-grid">
|
||||
|
||||
@@ -77,10 +77,13 @@ export class EnrollmentTokenForm extends WithBrandConfig(ModelForm<EnrollmentTok
|
||||
value=${ifDefined(this.instance?.name)}
|
||||
required
|
||||
></ak-text-input>
|
||||
<ak-form-element-horizontal label=${msg("Device Group")} name="deviceGroup">
|
||||
<ak-form-element-horizontal label=${msg("Device Access Group")} name="deviceGroup">
|
||||
<ak-endpoints-device-group-search
|
||||
.group=${this.instance?.deviceGroup}
|
||||
></ak-endpoints-device-group-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Select a device access group to be added to upon enrollment.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="expiring">
|
||||
<label class="pf-c-switch">
|
||||
|
||||
@@ -68,7 +68,7 @@ export class DeviceViewPage extends AKElement {
|
||||
? msg(str`Device ${this.device?.name}`)
|
||||
: msg("Loading device..."),
|
||||
description: this.device?.facts.data.os
|
||||
? this.device?.facts.data.os?.name + " " + this.device?.facts.data.os?.version
|
||||
? `${this.device?.facts.data.os?.name} ${this.device?.facts.data.os?.version}`
|
||||
: undefined,
|
||||
icon: "fa fa-laptop",
|
||||
});
|
||||
@@ -110,7 +110,7 @@ export class DeviceViewPage extends AKElement {
|
||||
?good=${this.device.facts.data.network?.firewallEnabled}
|
||||
></ak-status-label>`,
|
||||
],
|
||||
[msg("Group"), this.device.accessGroupObj?.name ?? "-"],
|
||||
[msg("Device access group"), this.device.accessGroupObj?.name ?? "-"],
|
||||
[
|
||||
msg("Actions"),
|
||||
html`<ak-forms-modal>
|
||||
@@ -162,13 +162,13 @@ export class DeviceViewPage extends AKElement {
|
||||
></ak-status-label>`,
|
||||
],
|
||||
[
|
||||
msg("Disk size"),
|
||||
msg("Primary disk size"),
|
||||
rootDisk?.capacityTotalBytes
|
||||
? getSize(rootDisk.capacityTotalBytes)
|
||||
: "-",
|
||||
],
|
||||
[
|
||||
msg("Disk usage"),
|
||||
msg("Primary disk usage"),
|
||||
rootDisk?.capacityTotalBytes && rootDisk.capacityUsedBytes
|
||||
? html`<progress
|
||||
value="${rootDisk.capacityUsedBytes}"
|
||||
|
||||
@@ -91,6 +91,20 @@ export class DataExportListPage extends TablePage<DataExport> {
|
||||
</div>
|
||||
</dl>`;
|
||||
}
|
||||
|
||||
protected renderEmpty(_inner?: TemplateResult): TemplateResult {
|
||||
return super.renderEmpty(
|
||||
html`<ak-empty-state icon=${this.pageIcon}
|
||||
><span
|
||||
>${msg(
|
||||
html`To create a data export, navigate to
|
||||
<a href="#/identity/users">Directory > Users</a> or to
|
||||
<a href="#/events/log">Events > Logs</a>.`,
|
||||
)}</span
|
||||
>
|
||||
</ak-empty-state>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -16,18 +16,33 @@ import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
// Same regex is used in the backend as well
|
||||
const VALID_FILE_NAME_PATTERN = /^[a-zA-Z0-9._/-]+$/;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source
|
||||
// This is perfect for the "pattern" attribute
|
||||
const VALID_FILE_NAME_PATTERN_STRING = VALID_FILE_NAME_PATTERN.source;
|
||||
|
||||
// Note: browsers compile `pattern` using the new `v` RegExp flag (Unicode sets). Under `/v`,
|
||||
// both `/` and `-` must be escaped inside character classes.
|
||||
const VALID_FILE_NAME_PATTERN_STRING = "^[a-zA-Z0-9._\\/\\-]+$";
|
||||
|
||||
function assertValidFileName(fileName: string): void {
|
||||
if (!VALID_FILE_NAME_PATTERN.test(fileName)) {
|
||||
throw new Error(
|
||||
msg("Filename can only contain letters, numbers, dots, hyphens, and underscores"),
|
||||
msg(
|
||||
"Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) return "";
|
||||
return fileName.slice(lastDot);
|
||||
}
|
||||
|
||||
function hasBasenameExtension(fileName: string): boolean {
|
||||
const baseName = fileName.split("/").pop() ?? fileName;
|
||||
const lastDot = baseName.lastIndexOf(".");
|
||||
return lastDot > 0;
|
||||
}
|
||||
|
||||
@customElement("ak-file-upload-form")
|
||||
export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
@property({ type: String, useDefault: true })
|
||||
@@ -57,36 +72,36 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
throw new PreventFormSubmit("Selected file not provided", this);
|
||||
}
|
||||
|
||||
assertValidFileName(this.selectedFile.name);
|
||||
|
||||
const api = new AdminApi(DEFAULT_CONFIG);
|
||||
const customName = typeof data.fileName === "string" ? data.fileName.trim() : "";
|
||||
const customName = typeof data.name === "string" ? data.name.trim() : "";
|
||||
|
||||
// If custom name provided, validate and append original extension
|
||||
// Only validate the original filename if no custom name is provided
|
||||
let finalName = this.selectedFile.name;
|
||||
if (customName) {
|
||||
assertValidFileName(customName);
|
||||
const ext = this.selectedFile.name.substring(this.selectedFile.name.lastIndexOf("."));
|
||||
finalName = customName + ext;
|
||||
const ext = getFileExtension(this.selectedFile.name);
|
||||
finalName =
|
||||
ext && !hasBasenameExtension(customName) ? `${customName}${ext}` : customName;
|
||||
} else {
|
||||
assertValidFileName(this.selectedFile.name);
|
||||
}
|
||||
|
||||
return api
|
||||
.adminFileCreate({
|
||||
file: this.selectedFile,
|
||||
name: finalName,
|
||||
usage: this.usage,
|
||||
})
|
||||
.then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: msg("File uploaded successfully"),
|
||||
});
|
||||
assertValidFileName(finalName);
|
||||
|
||||
this.reset();
|
||||
})
|
||||
.finally(() => {
|
||||
this.clearFileInput();
|
||||
});
|
||||
await api.adminFileCreate({
|
||||
file: this.selectedFile,
|
||||
name: finalName,
|
||||
usage: this.usage,
|
||||
});
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: msg("File uploaded successfully"),
|
||||
});
|
||||
|
||||
this.reset();
|
||||
this.clearFileInput();
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
@@ -101,7 +116,7 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
@change=${this.#fileChangeListener}
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("File Name")} name="fileName">
|
||||
<ak-form-element-horizontal label=${msg("File Name")} name="name">
|
||||
<input
|
||||
type="text"
|
||||
class="pf-c-form-control"
|
||||
|
||||
@@ -95,7 +95,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
|
||||
>
|
||||
</ak-rbac-role-object-permission-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">
|
||||
${msg("Assign role permissions")}
|
||||
${msg("Assign Object Permission")}
|
||||
</button>
|
||||
</ak-forms-modal>`;
|
||||
}
|
||||
@@ -135,9 +135,9 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
|
||||
const assignedToModel = item.modelPermissions.some(
|
||||
(uperm) => uperm.codename === perm.codename,
|
||||
);
|
||||
const assignedToObject = item.objectPermissions.some(
|
||||
(uperm) => uperm.codename === perm.codename,
|
||||
);
|
||||
const assignedToObject = item.objectPermissions
|
||||
.filter((uperm) => uperm.objectPk === this.objectPk)
|
||||
.some((uperm) => uperm.codename === perm.codename);
|
||||
|
||||
let tooltip: string | null = null;
|
||||
if (assignedToModel && assignedToObject) {
|
||||
|
||||
@@ -51,6 +51,11 @@ export class NavigationButtons extends WithSession(AKElement) {
|
||||
Styles,
|
||||
];
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.refreshNotifications();
|
||||
}
|
||||
|
||||
protected async refreshNotifications(): Promise<void> {
|
||||
const { currentUser } = this;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type Group<T> = [string, T[]];
|
||||
|
||||
export interface ISearchSelectBase<T> {
|
||||
blankable?: boolean;
|
||||
readOnly?: boolean;
|
||||
query?: string;
|
||||
objects?: T[];
|
||||
selectedObject: T | null;
|
||||
@@ -93,6 +94,14 @@ export abstract class SearchSelectBase<T>
|
||||
@property({ type: Boolean })
|
||||
public creatable?: boolean;
|
||||
|
||||
/**
|
||||
* Prevent user interaction while still rendering the current value.
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly = false;
|
||||
|
||||
/**
|
||||
* An initial string to filter the search contents,
|
||||
* and the value of the input which further serves to restrict the search.
|
||||
@@ -254,6 +263,8 @@ export abstract class SearchSelectBase<T>
|
||||
}
|
||||
|
||||
#searchListener = (event: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
const value = (event.target as SearchSelectView).rawValue;
|
||||
|
||||
if (!value) {
|
||||
@@ -277,6 +288,8 @@ export abstract class SearchSelectBase<T>
|
||||
};
|
||||
|
||||
private onSelect(event: InputEvent) {
|
||||
if (this.readOnly) return;
|
||||
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
|
||||
if (!value) {
|
||||
@@ -381,6 +394,7 @@ export abstract class SearchSelectBase<T>
|
||||
.options=${options}
|
||||
value=${ifPresent(value)}
|
||||
?blankable=${this.blankable}
|
||||
?readonly=${this.readOnly}
|
||||
label=${ifPresent(this.label)}
|
||||
name=${ifPresent(this.name)}
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ISearchSelectView {
|
||||
value?: string;
|
||||
open: boolean;
|
||||
blankable: boolean;
|
||||
readOnly: boolean;
|
||||
caseSensitive: boolean;
|
||||
name?: string;
|
||||
placeholder: string;
|
||||
@@ -126,6 +127,14 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
@property({ type: Boolean })
|
||||
public blankable = false;
|
||||
|
||||
/**
|
||||
* Prevents user interaction while showing the current value.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly = false;
|
||||
|
||||
/**
|
||||
* If not managed, make the matcher case-sensitive during interaction. If managed,
|
||||
* the manager must handle this.
|
||||
@@ -248,6 +257,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener = (_ev: Event) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
this.open = !this.open;
|
||||
this.#inputRef.value?.focus();
|
||||
};
|
||||
@@ -263,6 +274,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
#searchKeyupListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -277,6 +290,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#searchKeydownListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!this.open) return;
|
||||
|
||||
switch (event.key) {
|
||||
@@ -339,6 +354,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
#inputListener = (_ev: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!this.managed) {
|
||||
this.findValueForInput();
|
||||
this.requestUpdate();
|
||||
@@ -356,6 +373,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#listKeydownListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -364,6 +383,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#changeListener = (event: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
@@ -441,6 +462,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
@keyup=${this.#searchKeyupListener}
|
||||
@keydown=${this.#searchKeydownListener}
|
||||
value=${this.displayValue}
|
||||
?readonly=${this.readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,8 @@ Firefox has some known issues regarding TouchID (see https://bugzilla.mozilla.or
|
||||
|
||||
Passwordless authentication currently only supports WebAuthn devices, which provides for the use of passkeys, security keys and biometrics. For an alternate passwordless setup, see [Password stage](../password/index.md#passwordless-login), which supports other types.
|
||||
|
||||
If you want users to authenticate with a passkey via the browser's built-in passkey/autofill UI on the **Identification** screen ("conditional UI" / passkey autofill), configure it in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This requires a **discoverable credential (aka resident key)**.
|
||||
|
||||
To configure passwordless authentication, create a new Flow with the designation set to _Authentication_.
|
||||
|
||||
As first stage, add an _Authenticator validation_ stage, with the WebAuthn device class allowed.
|
||||
|
||||
@@ -26,6 +26,46 @@ The CAPTCHA stage you use must be configured to use the "Invisible" mode, otherw
|
||||
|
||||
To run a CAPTCHA process in the background while the user is entering their identification, a CAPTCHA stage can be selected here. If a CAPTCHA stage is selected in the Identification stage, the CAPTCHA stage should not be bound to the flow.
|
||||
|
||||
## Passkey autofill (WebAuthn conditional UI):ak-version[2025.12]
|
||||
|
||||
When configured, the Identification stage can offer passkey login directly from the browser's passkey/autofill UI (also known as "conditional UI"). This allows a user to select a passkey without first typing their username.
|
||||
|
||||
authentik will automatically fall back to the normal identification flow when passkey autofill is not available.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **HTTPS** is required for WebAuthn (except on `localhost`).
|
||||
- **Browser support** for WebAuthn conditional mediation is required.
|
||||
- Users must have a compatible **discoverable credential (aka resident key)** (most passkeys created by platform authenticators and password managers are discoverable).
|
||||
- **Correct domain**: users must access authentik using the same hostname the passkey was created for.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Flows and Stages** > **Stages** and either create or edit an [Authenticator validation stage](../authenticator_validate/index.mdx) that allows the **WebAuthn** device class.
|
||||
3. Navigate to **Flows and Stages** > **Stages** and edit your Identification stage. Under **Passkey settings** set **WebAuthn Authenticator Validation Stage** to the Authenticator validation stage from step 2.
|
||||
4. Click **Update** to save the changes.
|
||||
5. Ensure users have enrolled a passkey/WebAuthn device (for example using the [WebAuthn / FIDO2 / Passkeys Authenticator setup stage](../authenticator_webauthn/index.mdx)).
|
||||
|
||||
### Notes
|
||||
|
||||
- The passkey prompt is triggered by the browser when the user focuses the username field.
|
||||
- If a user has multiple passkeys, the browser will show a picker.
|
||||
- If passkey login is used, the flow context will have `auth_method` set to `auth_webauthn_pwl`.
|
||||
- In the default authentication flow blueprint, authentik skips the MFA validation stage after passkey login using an expression policy. If you want passkey login to still require an additional factor, disable or adjust that policy binding on the MFA stage.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **No passkey prompt appears**
|
||||
- Ensure the Identification stage has **WebAuthn Authenticator Validation Stage** set.
|
||||
- Ensure you're using **HTTPS** (except on `localhost`).
|
||||
- Check browser support for conditional UI.
|
||||
- Ensure the login page is not embedded in an iframe as some browsers block conditional UI outside top-level browsing contexts.
|
||||
|
||||
- **Passkey prompt appears, but login falls back to username/password**
|
||||
- Ensure the referenced Authenticator validation stage allows the **WebAuthn** device class.
|
||||
- Ensure the user has a valid, confirmed WebAuthn device enrolled.
|
||||
|
||||
## Enrollment/Recovery Flow
|
||||
|
||||
These fields specify if and which flows are linked on the form. The enrollment flow is linked as `Need an account? Sign up.`, and the recovery flow is linked as `Forgot username or password?`.
|
||||
|
||||
@@ -8,6 +8,8 @@ This is a generic password prompt which authenticates the current `pending_user`
|
||||
|
||||
There are two different ways to configure passwordless authentication; you can follow the instructions [here](../authenticator_validate/index.mdx#passwordless-authentication) to allow users to directly authenticate with their authenticator (only supported for WebAuthn devices), or dynamically skip the password stage depending on the users device, which is documented here.
|
||||
|
||||
If you want users to be able to pick a passkey from the browser's passkey/autofill UI without entering a username first, configure **Passkey autofill (WebAuthn conditional UI)** in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This is separate from configuring a dedicated passwordless flow, and can be used alongside normal identification flows.
|
||||
|
||||
Depending on what kind of device you want to require the user to have:
|
||||
|
||||
#### WebAuthn
|
||||
|
||||
@@ -394,6 +394,8 @@ When documenting errors, follow this structure:
|
||||
- **Diagrams**:
|
||||
- Use [Mermaid](https://mermaid.js.org/) for creating diagrams directly in markdown. Mermaid is our preferred tool for documentation diagrams as it allows for version control and easy updates.
|
||||
- For more complex diagrams, you can use tools like [Draw.io](https://draw.io). Ensure high contrast and text descriptions.
|
||||
- **authentik icons**:
|
||||
- For authentik icons in integration guides, reference assets from the user's own self-hosted instance to avoid external calls, for example: `https://authentik.company/static/dist/assets/icons/icon.svg`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -389,6 +389,34 @@ If you had persistence for Redis configured, you can delete the PVC and PV after
|
||||
- web/flows: improvements for hCaptcha (cherry-pick #16882 to version-2025.10) (#18128)
|
||||
- web/sfe: downgrade bootstrap that was accidentally upgraded (cherry-pick #18157 to version-2025.10) (#18171)
|
||||
|
||||
## Fixed in 2025.10.3
|
||||
|
||||
- core: list applications fix (cherry-pick #18798 to version-2025.10) (#18827)
|
||||
- core: optimize list applications (cherry-pick #18330 to version-2025.10) (#18791)
|
||||
- enterprise/stages/mtls: fix traefik certificate parsing (cherry-pick #18607 to version-2025.10) (#18645)
|
||||
- flows: refresh unauthenticated tabs (cherry-pick #18621 to version-2025.10) (#18633)
|
||||
- lib/sync/outgoing: check if there is a provider before creating tasks (cherry-pick #18394 to version-2025.10) (#18397)
|
||||
- outpost/proxyv2: more tests, fix pg password with spaces, and existing session on restart (cherry-pick #18211 to version-2025.10) (#18742)
|
||||
- outposts: set container healthcheck inline (cherry-pick #18298 to version-2025.10) (#18370)
|
||||
- packages/django-channels-postgres: fix notify size check (cherry-pick #18347 to version-2025.10) (#18409)
|
||||
- packages/django-dramatiq-postgres: broker: close django connections on consumer close (cherry-pick #18833 to version-2025.10) (#18835)
|
||||
- providers/scim: compare users/groups before sending update request (cherry-pick #18456 to version-2025.10) (#18465)
|
||||
- root: fix missing authentik_device cookie causing error (cherry-pick #18642 to version-2025.10) (#18644)
|
||||
- root: skip current tab when refreshing others (cherry-pick #18674 to version-2025.10) (#18675)
|
||||
- sources/ldap: make server info optional (cherry-pick #18648 to version-2025.10) (#18654)
|
||||
- stages/prompt: set allow_blank for \_read_only fields (cherry-pick #18297 to version-2025.10) (#18406)
|
||||
- web: Fix row expansion on modal trigger buttons. (cherry-pick #18412 to version-2025.10) (#18647)
|
||||
- web: Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18373)
|
||||
- web: Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18408)
|
||||
- web: Hide device picker when challenges are not present. (cherry-pick #18611 to version-2025.10) (#18681)
|
||||
- web: Improved table selection behavior (cherry-pick #18622 to version-2025.10) (#18685)
|
||||
- web: revert Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18407)
|
||||
- web/admin: add entitlement search (cherry-pick #18291 to version-2025.10) (#18390)
|
||||
- web/admin: fix brands default switch label (cherry-pick #18518 to version-2025.10) (#18522)
|
||||
- web/admin: fix event volume chart not updating with query (cherry-pick #18649 to version-2025.10) (#18653)
|
||||
- web/admin: fix wording in password stage (cherry-pick #18393 to version-2025.10) (#18395)
|
||||
- web/admin: fixes capitalization in application wizard title (cherry-pick #17959 to version-2025.10) (#17962)
|
||||
|
||||
## API Changes
|
||||
|
||||
#### What's Changed
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@ To assign or remove _object_ permissions for a specific role:
|
||||
2. Select a specific role by clicking on the role's name.
|
||||
3. Click the **Permissions** tab at the top of the page, then click the **Permissions on this object** tab
|
||||
4. To assign permissions that another _role_ has on this specific role:
|
||||
1. Click **Assign role permissions**.
|
||||
1. Click **Assign Object Permission**.
|
||||
2. In the **Role** drop-down, select the role object.
|
||||
3. Use the toggles to set which permissions on that selected role object you want to grant to the specific role.
|
||||
4. Click **Assign** to save your settings and close the box.
|
||||
|
||||
Reference in New Issue
Block a user