From c30d1a478dcfe0b923cca58992613f46dca4d86e Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Tue, 2 Dec 2025 18:01:51 +0100 Subject: [PATCH] files: rework (#17535) Co-authored-by: Dominic R Co-authored-by: Jens Langhammer Co-authored-by: Tana M Berry --- .github/actions/setup/docker-compose.yml | 17 + Dockerfile | 5 +- authentik/admin/files/__init__.py | 0 authentik/admin/files/api.py | 258 ++++++++++++ authentik/admin/files/apps.py | 8 + authentik/admin/files/backends/__init__.py | 0 authentik/admin/files/backends/base.py | 134 ++++++ authentik/admin/files/backends/file.py | 114 +++++ authentik/admin/files/backends/passthrough.py | 43 ++ authentik/admin/files/backends/s3.py | 213 ++++++++++ authentik/admin/files/backends/static.py | 53 +++ .../admin/files/backends/tests/__init__.py | 0 .../files/backends/tests/test_file_backend.py | 167 ++++++++ .../tests/test_passthrough_backend.py | 67 +++ .../files/backends/tests/test_s3_backend.py | 109 +++++ .../backends/tests/test_static_backend.py | 42 ++ authentik/admin/files/fields.py | 7 + authentik/admin/files/manager.py | 141 +++++++ authentik/admin/files/tests/__init__.py | 1 + authentik/admin/files/tests/test_api.py | 229 ++++++++++ authentik/admin/files/tests/test_manager.py | 99 +++++ .../admin/files/tests/test_validation.py | 110 +++++ authentik/admin/files/tests/utils.py | 114 +++++ authentik/admin/files/urls.py | 8 + authentik/admin/files/usage.py | 17 + authentik/admin/files/validation.py | 79 ++++ authentik/api/v3/config.py | 11 +- .../tests/test_v1_conditional_fields.py | 22 +- authentik/brands/api.py | 2 +- ...anding_default_flow_background_and_more.py | 35 ++ authentik/brands/models.py | 32 +- authentik/core/api/applications.py | 55 +-- authentik/core/api/sources.py | 62 +-- authentik/core/api/used_by.py | 1 + ...application_meta_icon_alter_source_icon.py | 24 ++ authentik/core/models.py | 44 +- authentik/core/tests/test_applications_api.py | 96 +---- authentik/flows/api/flows.py | 73 +--- .../migrations/0030_alter_flow_background.py | 21 + authentik/flows/models.py | 21 +- authentik/flows/tests/test_api.py | 2 +- authentik/lib/config.py | 12 +- authentik/lib/default.yml | 40 +- authentik/lib/utils/dict.py | 14 + authentik/lib/utils/file.py | 56 --- .../0007_alter_systempermission_options.py | 30 ++ authentik/rbac/models.py | 2 + authentik/root/settings.py | 41 +- authentik/root/storages.py | 144 ------- blueprints/schema.json | 84 +--- docker-compose.yml | 4 +- internal/config/struct.go | 28 +- internal/web/static.go | 103 ++++- schema.yml | 396 +++++++++--------- scripts/generate_config.py | 18 +- scripts/generate_docker_compose.py | 4 +- web/src/admin/AdminInterface/AboutModal.ts | 17 +- web/src/admin/AdminInterface/AdminSidebar.ts | 3 +- web/src/admin/Routes.ts | 4 + web/src/admin/applications/ApplicationForm.ts | 75 +--- .../admin/applications/ApplicationListPage.ts | 2 +- .../admin/applications/ApplicationViewPage.ts | 2 +- .../ak-application-wizard-application-step.ts | 26 +- web/src/admin/brands/BrandForm.ts | 39 +- web/src/admin/files/FileListPage.ts | 137 ++++++ web/src/admin/files/FileUploadForm.ts | 126 ++++++ web/src/admin/flows/FlowForm.ts | 115 +---- .../sources/kerberos/KerberosSourceForm.ts | 97 +---- .../admin/sources/oauth/OAuthSourceForm.ts | 93 +--- web/src/admin/sources/plex/PlexSourceForm.ts | 91 +--- web/src/admin/sources/saml/SAMLSourceForm.ts | 93 +--- .../sources/telegram/TelegramSourceForm.ts | 4 +- web/src/admin/users/UserApplicationTable.ts | 2 +- web/src/components/ak-file-search-input.ts | 153 +++++++ web/src/components/ak-page-navbar.ts | 14 +- web/src/elements/AppIcon.css | 4 + web/src/elements/AppIcon.ts | 55 ++- web/src/elements/forms/DeleteBulkForm.ts | 3 + web/src/elements/forms/Form.ts | 2 +- web/src/elements/forms/ProxyForm.ts | 2 + .../forms/SearchSelect/SearchSelect.ts | 44 ++ .../SearchSelect/ak-search-select-view.ts | 3 + web/src/elements/sync/SyncObjectForm.ts | 2 +- web/src/elements/tasks/ScheduleForm.ts | 2 +- web/src/elements/utils/images.ts | 56 ++- web/src/flow/FlowExecutor.ts | 10 +- .../api-browser/index.entrypoint.css | 7 + .../api-browser/index.entrypoint.ts | 8 +- .../authentik/components/Login/login.css | 12 + web/src/user/LibraryApplication/index.ts | 2 +- web/src/user/index.entrypoint.css | 16 +- web/src/user/index.entrypoint.ts | 8 +- website/docs/customize/branding.md | 12 +- website/docs/customize/files.md | 20 + .../configuration/configuration.mdx | 154 ++++++- website/docs/releases/2025/v2025.12.md | 76 ++++ website/docs/sidebar.mjs | 1 + website/docs/sys-mgmt/brands.md | 6 +- website/docs/sys-mgmt/ops/storage-s3.md | 42 +- 99 files changed, 3648 insertions(+), 1504 deletions(-) create mode 100644 authentik/admin/files/__init__.py create mode 100644 authentik/admin/files/api.py create mode 100644 authentik/admin/files/apps.py create mode 100644 authentik/admin/files/backends/__init__.py create mode 100644 authentik/admin/files/backends/base.py create mode 100644 authentik/admin/files/backends/file.py create mode 100644 authentik/admin/files/backends/passthrough.py create mode 100644 authentik/admin/files/backends/s3.py create mode 100644 authentik/admin/files/backends/static.py create mode 100644 authentik/admin/files/backends/tests/__init__.py create mode 100644 authentik/admin/files/backends/tests/test_file_backend.py create mode 100644 authentik/admin/files/backends/tests/test_passthrough_backend.py create mode 100644 authentik/admin/files/backends/tests/test_s3_backend.py create mode 100644 authentik/admin/files/backends/tests/test_static_backend.py create mode 100644 authentik/admin/files/fields.py create mode 100644 authentik/admin/files/manager.py create mode 100644 authentik/admin/files/tests/__init__.py create mode 100644 authentik/admin/files/tests/test_api.py create mode 100644 authentik/admin/files/tests/test_manager.py create mode 100644 authentik/admin/files/tests/test_validation.py create mode 100644 authentik/admin/files/tests/utils.py create mode 100644 authentik/admin/files/urls.py create mode 100644 authentik/admin/files/usage.py create mode 100644 authentik/admin/files/validation.py create mode 100644 authentik/brands/migrations/0011_alter_brand_branding_default_flow_background_and_more.py create mode 100644 authentik/core/migrations/0054_alter_application_meta_icon_alter_source_icon.py create mode 100644 authentik/flows/migrations/0030_alter_flow_background.py delete mode 100644 authentik/lib/utils/file.py create mode 100644 authentik/rbac/migrations/0007_alter_systempermission_options.py delete mode 100644 authentik/root/storages.py create mode 100644 web/src/admin/files/FileListPage.ts create mode 100644 web/src/admin/files/FileUploadForm.ts create mode 100644 web/src/components/ak-file-search-input.ts create mode 100644 website/docs/customize/files.md create mode 100644 website/docs/releases/2025/v2025.12.md diff --git a/.github/actions/setup/docker-compose.yml b/.github/actions/setup/docker-compose.yml index df6cd068df..cf2d63844c 100644 --- a/.github/actions/setup/docker-compose.yml +++ b/.github/actions/setup/docker-compose.yml @@ -16,7 +16,24 @@ services: ports: - 6379:6379 restart: always + s3: + container_name: s3 + image: docker.io/zenko/cloudserver + environment: + REMOTE_MANAGEMENT_DISABLE: "1" + SCALITY_ACCESS_KEY_ID: accessKey1 + SCALITY_SECRET_ACCESS_KEY: secretKey1 + ports: + - 8020:8000 + volumes: + - s3-data:/usr/src/app/localData + - s3-metadata:/usr/scr/app/localMetadata + restart: always volumes: db-data: driver: local + s3-data: + driver: local + s3-metadata: + driver: local diff --git a/Dockerfile b/Dockerfile index 3d128d325d..05dbcecf3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -163,10 +163,11 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ - mkdir -p /certs /media /blueprints && \ + mkdir -p /certs /data /media /blueprints && \ + ln -s /media /data/media && \ mkdir -p /authentik/.ssh && \ mkdir -p /ak-root && \ - chown authentik:authentik /certs /media /authentik/.ssh /ak-root + chown authentik:authentik /certs /data /data/media /media /authentik/.ssh /ak-root COPY ./authentik/ /authentik COPY ./pyproject.toml / diff --git a/authentik/admin/files/__init__.py b/authentik/admin/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/admin/files/api.py b/authentik/admin/files/api.py new file mode 100644 index 0000000000..750c35822b --- /dev/null +++ b/authentik/admin/files/api.py @@ -0,0 +1,258 @@ +import mimetypes + +from django.db.models import Q +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema +from guardian.shortcuts import get_objects_for_user +from rest_framework.exceptions import ValidationError +from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import SAFE_METHODS +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentik.admin.files.fields import FileField as AkFileField +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileApiUsage +from authentik.admin.files.validation import validate_upload_file_name +from authentik.api.validation import validate +from authentik.core.api.used_by import DeleteAction, UsedBySerializer +from authentik.core.api.utils import PassiveSerializer +from authentik.events.models import Event, EventAction +from authentik.lib.utils.reflection import get_apps +from authentik.rbac.permissions import HasPermission + +MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB + + +def get_mime_from_filename(filename: str) -> str: + mime_type, _ = mimetypes.guess_type(filename) + return mime_type or "application/octet-stream" + + +class FileView(APIView): + pagination_class = None + parser_classes = [MultiPartParser] + + def get_permissions(self): + return [ + HasPermission( + "authentik_rbac.view_media_files" + if self.request.method in SAFE_METHODS + else "authentik_rbac.manage_media_files" + )() + ] + + class FileListParameters(PassiveSerializer): + usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value) + search = CharField(required=False) + manageable_only = BooleanField(required=False, default=False) + + class FileListSerializer(PassiveSerializer): + name = CharField() + mime_type = CharField() + url = CharField() + + @extend_schema( + parameters=[FileListParameters], + responses={200: FileListSerializer(many=True)}, + ) + @validate(FileListParameters, location="query") + def get(self, request: Request, query: FileListParameters) -> Response: + """List files from storage backend.""" + params = query.validated_data + + try: + usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value)) + except ValueError as exc: + raise ValidationError( + f"Invalid usage parameter provided: {params.get('usage')}" + ) from exc + + # Backend is source of truth - list all files from storage + manager = get_file_manager(usage) + files = manager.list_files(manageable_only=params.get("manageable_only", False)) + search_query = params.get("search", "") + if search_query: + files = filter(lambda file: search_query in file.lower(), files) + files = [ + FileView.FileListSerializer( + data={ + "name": file, + "url": manager.file_url(file), + "mime_type": get_mime_from_filename(file), + } + ) + for file in files + ] + for file in files: + file.is_valid(raise_exception=True) + + return Response([file.data for file in files]) + + class FileUploadSerializer(PassiveSerializer): + file = FileField(required=True) + name = CharField(required=False, allow_blank=True) + usage = CharField(required=False, default=FileApiUsage.MEDIA.value) + + @extend_schema( + request=FileUploadSerializer, + responses={200: None}, + ) + @validate(FileUploadSerializer) + def post(self, request: Request, body: FileUploadSerializer) -> Response: + """Upload file to storage backend.""" + file = body.validated_data["file"] + name = body.validated_data.get("name", "").strip() + usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value) + + # Validate file size and type + if file.size > MAX_FILE_SIZE_BYTES: + raise ValidationError( + { + "file": [ + _( + f"File size ({file.size}B) exceeds maximum allowed " + f"size ({MAX_FILE_SIZE_BYTES}B)." + ) + ] + } + ) + + try: + usage = FileApiUsage(usage_value) + except ValueError as exc: + raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc + + # Use original filename + if not name: + name = file.name + + # Sanitize path to prevent directory traversal + validate_upload_file_name(name, ValidationError) + + manager = get_file_manager(usage) + + # Check if file already exists + if manager.file_exists(name): + raise ValidationError({"name": ["A file with this name already exists."]}) + + # Save to backend + with manager.save_file_stream(name) as f: + f.write(file.read()) + + Event.new( + EventAction.MODEL_CREATED, + model={ + "app": "authentik_admin_files", + "model_name": "File", + "pk": name, + "name": name, + "usage": usage.value, + "mime_type": get_mime_from_filename(name), + }, + ).from_http(request) + + return Response() + + class FileDeleteParameters(PassiveSerializer): + name = CharField() + usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value) + + @extend_schema( + parameters=[FileDeleteParameters], + responses={200: None}, + ) + @validate(FileDeleteParameters, location="query") + def delete(self, request: Request, query: FileDeleteParameters) -> Response: + """Delete file from storage backend.""" + params = query.validated_data + + validate_upload_file_name(params.get("name", ""), ValidationError) + + try: + usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value)) + except ValueError as exc: + raise ValidationError( + f"Invalid usage parameter provided: {params.get('usage')}" + ) from exc + + manager = get_file_manager(usage) + + # Delete from backend + manager.delete_file(params.get("name")) + + # Audit log for file deletion + Event.new( + EventAction.MODEL_DELETED, + model={ + "app": "authentik_admin_files", + "model_name": "File", + "pk": params.get("name"), + "name": params.get("name"), + "usage": usage.value, + }, + ).from_http(request) + + return Response() + + +class FileUsedByView(APIView): + pagination_class = None + + def get_permissions(self): + return [ + HasPermission( + "authentik_rbac.view_media_files" + if self.request.method in SAFE_METHODS + else "authentik_rbac.manage_media_files" + )() + ] + + class FileUsedByParameters(PassiveSerializer): + name = CharField() + + @extend_schema( + parameters=[FileUsedByParameters], + responses={200: UsedBySerializer(many=True)}, + ) + @validate(FileUsedByParameters, location="query") + def get(self, request: Request, query: FileUsedByParameters) -> Response: + params = query.validated_data + + models_and_fields = {} + for app in get_apps(): + for model in app.get_models(): + if model._meta.abstract: + continue + for field in model._meta.get_fields(): + if isinstance(field, AkFileField): + models_and_fields.setdefault(model, []).append(field.name) + + used_by = [] + + for model, fields in models_and_fields.items(): + app = model._meta.app_label + model_name = model._meta.model_name + + q = Q() + for field in fields: + q |= Q(**{field: params.get("name")}) + + objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model) + objs = objs.filter(q) + for obj in objs: + serializer = UsedBySerializer( + data={ + "app": model._meta.app_label, + "model_name": model._meta.model_name, + "pk": str(obj.pk), + "name": str(obj), + "action": DeleteAction.LEFT_DANGLING, + } + ) + serializer.is_valid() + used_by.append(serializer.data) + + return Response(used_by) diff --git a/authentik/admin/files/apps.py b/authentik/admin/files/apps.py new file mode 100644 index 0000000000..724965006a --- /dev/null +++ b/authentik/admin/files/apps.py @@ -0,0 +1,8 @@ +from authentik.blueprints.apps import ManagedAppConfig + + +class AuthentikFilesConfig(ManagedAppConfig): + name = "authentik.admin.files" + label = "authentik_admin_files" + verbose_name = "authentik Files" + default = True diff --git a/authentik/admin/files/backends/__init__.py b/authentik/admin/files/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/admin/files/backends/base.py b/authentik/admin/files/backends/base.py new file mode 100644 index 0000000000..e8253699a8 --- /dev/null +++ b/authentik/admin/files/backends/base.py @@ -0,0 +1,134 @@ +from collections.abc import Generator, Iterator + +from django.http.request import HttpRequest +from structlog.stdlib import get_logger + +from authentik.admin.files.usage import FileUsage + +LOGGER = get_logger() + + +class Backend: + """ + Base class for file storage backends. + + Class attributes: + allowed_usages: List of usages that can be used with this backend + """ + + allowed_usages: list[FileUsage] + + def __init__(self, usage: FileUsage): + """ + Initialize backend for the given usage type. + + Args: + usage: FileUsage type enum value + """ + self.usage = usage + LOGGER.debug( + "Initializing storage backend", + backend=self.__class__.__name__, + usage=usage.value, + ) + + def supports_file(self, name: str) -> bool: + """ + Check if this backend can handle the given file path. + + Args: + name: File path to check + + Returns: + True if this backend supports this file path + """ + raise NotImplementedError + + def list_files(self) -> Generator[str]: + """ + List all files stored in this backend. + + Yields: + Relative file paths + """ + raise NotImplementedError + + def file_url(self, name: str, request: HttpRequest | None = None) -> str: + """ + Get URL for accessing the file. + + Args: + file_path: Relative file path + request: Optional Django HttpRequest for fully qualifed URL building + + Returns: + URL to access the file (may be relative or absolute depending on backend) + """ + raise NotImplementedError + + +class ManageableBackend(Backend): + """ + Base class for manageable file storage backends. + + Class attributes: + name: Canonical name of the storage backend, for use in configuration. + """ + + name: str + + @property + def manageable(self) -> bool: + """ + Whether this backend can actually be used for management. + + Used only for management check, not for created the backend + """ + raise NotImplementedError + + def save_file(self, name: str, content: bytes) -> None: + """ + Save file content to storage. + + Args: + file_path: Relative file path + content: File content as bytes + """ + raise NotImplementedError + + def save_file_stream(self, name: str) -> Iterator: + """ + Context manager for streaming file writes. + + Args: + file_path: Relative file path + + Returns: + Context manager that yields a writable file-like object + + FileUsage: + with backend.save_file_stream("output.csv") as f: + f.write(b"data...") + """ + raise NotImplementedError + + def delete_file(self, name: str) -> None: + """ + Delete file from storage. + + Args: + file_path: Relative file path + """ + raise NotImplementedError + + def file_exists(self, name: str) -> bool: + """ + Check if a file exists. + + Args: + file_path: Relative file path + + Returns: + True if file exists, False otherwise + """ + raise NotImplementedError diff --git a/authentik/admin/files/backends/file.py b/authentik/admin/files/backends/file.py new file mode 100644 index 0000000000..67eb6bb4a4 --- /dev/null +++ b/authentik/admin/files/backends/file.py @@ -0,0 +1,114 @@ +import os +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from datetime import timedelta +from hashlib import sha256 +from pathlib import Path + +import jwt +from django.conf import settings +from django.db import connection +from django.http.request import HttpRequest +from django.utils.timezone import now + +from authentik.admin.files.backends.base import ManageableBackend +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG +from authentik.lib.utils.time import timedelta_from_string + + +class FileBackend(ManageableBackend): + """Local filesystem backend for file storage. + + Stores files in a local directory structure: + - Path: {base_dir}/{usage}/{schema}/{filename} + - Supports full file management (upload, delete, list) + - Used when storage.backend=file (default) + """ + + name = "file" + allowed_usages = list(FileUsage) # All usages + + @property + def _base_dir(self) -> Path: + return Path( + CONFIG.get( + f"storage.{self.usage.value}.{self.name}.path", + CONFIG.get(f"storage.{self.name}.path", "./data"), + ) + ) + + @property + def base_path(self) -> Path: + """Path structure: {base_dir}/{usage}/{schema}""" + return self._base_dir / self.usage.value / connection.schema_name + + @property + def manageable(self) -> bool: + return ( + self.base_path.exists() + and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount()) + or (settings.DEBUG or settings.TEST) + ) + + def supports_file(self, name: str) -> bool: + """We support all files""" + return True + + def list_files(self) -> Generator[str]: + """List all files returning relative paths from base_path.""" + for root, _, files in os.walk(self.base_path): + for file in files: + full_path = Path(root) / file + rel_path = full_path.relative_to(self.base_path) + yield str(rel_path) + + def file_url(self, name: str, request: HttpRequest | None = None) -> str: + """Get URL for accessing the file.""" + expires_in = timedelta_from_string( + CONFIG.get( + f"storage.{self.usage.value}.{self.name}.url_expiry", + CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"), + ) + ) + + prefix = CONFIG.get("web.path", "/")[:-1] + path = f"{self.usage.value}/{connection.schema_name}/{name}" + token = jwt.encode( + payload={ + "path": path, + "exp": now() + expires_in, + "nbf": now() - timedelta(seconds=15), + }, + key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(), + algorithm="HS256", + ) + url = f"{prefix}/files/{path}?token={token}" + if request is None: + return url + return request.build_absolute_uri(url) + + def save_file(self, name: str, content: bytes) -> None: + """Save file to local filesystem.""" + path = self.base_path / Path(name) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w+b") as f: + f.write(content) + + @contextmanager + def save_file_stream(self, name: str) -> Iterator: + """Context manager for streaming file writes to local filesystem.""" + path = self.base_path / Path(name) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + yield f + + def delete_file(self, name: str) -> None: + """Delete file from local filesystem.""" + path = self.base_path / Path(name) + path.unlink(missing_ok=True) + + def file_exists(self, name: str) -> bool: + """Check if a file exists.""" + path = self.base_path / Path(name) + return path.exists() diff --git a/authentik/admin/files/backends/passthrough.py b/authentik/admin/files/backends/passthrough.py new file mode 100644 index 0000000000..e2773fd8df --- /dev/null +++ b/authentik/admin/files/backends/passthrough.py @@ -0,0 +1,43 @@ +from collections.abc import Generator + +from django.http.request import HttpRequest + +from authentik.admin.files.backends.base import Backend +from authentik.admin.files.usage import FileUsage + +EXTERNAL_URL_SCHEMES = ["http:", "https://"] +FONT_AWESOME_SCHEME = "fa://" + + +class PassthroughBackend(Backend): + """Passthrough backend for external URLs and special schemes. + + Handles external resources that aren't stored in authentik: + - Font Awesome icons (fa://...) + - HTTP/HTTPS URLs (http://..., https://...) + + Files that are "managed" by this backend are just passed through as-is. + No upload, delete, or listing operations are supported. + Only accessible through resolve_file_url when an external URL is detected. + """ + + allowed_usages = [FileUsage.MEDIA] + + def supports_file(self, name: str) -> bool: + """Check if file path is an external URL or Font Awesome icon.""" + if name.startswith(FONT_AWESOME_SCHEME): + return True + + for scheme in EXTERNAL_URL_SCHEMES: + if name.startswith(scheme): + return True + + return False + + def list_files(self) -> Generator[str]: + """External files cannot be listed.""" + yield from [] + + def file_url(self, name: str, request: HttpRequest | None = None) -> str: + """Return the URL as-is for passthrough files.""" + return name diff --git a/authentik/admin/files/backends/s3.py b/authentik/admin/files/backends/s3.py new file mode 100644 index 0000000000..18dee16f67 --- /dev/null +++ b/authentik/admin/files/backends/s3.py @@ -0,0 +1,213 @@ +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from tempfile import SpooledTemporaryFile +from urllib.parse import urlsplit + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError +from django.db import connection +from django.http.request import HttpRequest + +from authentik.admin.files.backends.base import ManageableBackend +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG +from authentik.lib.utils.time import timedelta_from_string + + +class S3Backend(ManageableBackend): + """S3-compatible object storage backend. + + Stores files in s3-compatible storage: + - Key prefix: {usage}/{schema}/{filename} + - Supports full file management (upload, delete, list) + - Generates presigned URLs for file access + - Used when storage.backend=s3 + """ + + allowed_usages = list(FileUsage) # All usages + name = "s3" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._config = {} + self._session = None + + def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]: + unset = object() + current = self._config.get(key, unset) + refreshed = CONFIG.refresh( + f"storage.{self.usage.value}.{self.name}.{key}", + CONFIG.refresh(f"storage.{self.name}.{key}", default), + ) + if current is unset: + current = refreshed + self._config[key] = refreshed + return (refreshed, current != refreshed) + + @property + def base_path(self) -> str: + """S3 key prefix: {usage}/{schema}/""" + return f"{self.usage.value}/{connection.schema_name}" + + @property + def bucket_name(self) -> str: + return CONFIG.get( + f"storage.{self.usage.value}.{self.name}.bucket_name", + CONFIG.get(f"storage.{self.name}.bucket_name"), + ) + + @property + def session(self) -> boto3.Session: + """Create boto3 session with configured credentials.""" + session_profile, session_profile_r = self._get_config("session_profile", None) + if session_profile is not None: + if session_profile_r or self._session is None: + self._session = boto3.Session(profile_name=session_profile) + return self._session + else: + return self._session + else: + access_key, access_key_r = self._get_config("access_key", None) + secret_key, secret_key_r = self._get_config("secret_key", None) + session_token, session_token_r = self._get_config("session_token", None) + if access_key_r or secret_key_r or session_token_r or self._session is None: + self._session = boto3.Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + aws_session_token=session_token, + ) + return self._session + else: + return self._session + + @property + def client(self): + """Create S3 client with configured endpoint and region.""" + endpoint_url = CONFIG.get( + f"storage.{self.usage.value}.{self.name}.endpoint", + CONFIG.get(f"storage.{self.name}.endpoint", None), + ) + use_ssl = CONFIG.get( + f"storage.{self.usage.value}.{self.name}.use_ssl", + CONFIG.get(f"storage.{self.name}.use_ssl", True), + ) + region_name = CONFIG.get( + f"storage.{self.usage.value}.{self.name}.region", + CONFIG.get(f"storage.{self.name}.region", None), + ) + addressing_style = CONFIG.get( + f"storage.{self.usage.value}.{self.name}.addressing_style", + CONFIG.get(f"storage.{self.name}.addressing_style", "auto"), + ) + + return self.session.client( + "s3", + endpoint_url=endpoint_url, + use_ssl=use_ssl, + region_name=region_name, + config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}), + ) + + @property + def manageable(self) -> bool: + return True + + def supports_file(self, name: str) -> bool: + """We support all files""" + return True + + def list_files(self) -> Generator[str]: + """List all files returning relative paths from base_path.""" + paginator = self.client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self.bucket_name, Prefix=f"{self.base_path}/") + + for page in pages: + for obj in page.get("Contents", []): + key = obj["Key"] + # Remove base path prefix to get relative path + rel_path = key.removeprefix(f"{self.base_path}/") + if rel_path: # Skip if it's just the directory itself + yield rel_path + + def file_url(self, name: str, request: HttpRequest | None = None) -> str: + """Generate presigned URL for file access.""" + use_https = CONFIG.get_bool( + f"storage.{self.usage.value}.{self.name}.secure_urls", + CONFIG.get_bool(f"storage.{self.name}.secure_urls", True), + ) + + params = { + "Bucket": self.bucket_name, + "Key": f"{self.base_path}/{name}", + } + + expires_in = timedelta_from_string( + CONFIG.get( + f"storage.{self.usage.value}.{self.name}.url_expiry", + CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"), + ) + ) + + url = self.client.generate_presigned_url( + "get_object", + Params=params, + ExpiresIn=expires_in.total_seconds(), + HttpMethod="GET", + ) + + # Support custom domain for S3-compatible storage (so not AWS) + # Well, can't you do custom domains on AWS as well? + custom_domain = CONFIG.get( + f"storage.{self.usage.value}.{self.name}.custom_domain", + CONFIG.get(f"storage.{self.name}.custom_domain", None), + ) + if custom_domain: + parsed = urlsplit(url) + scheme = "https" if use_https else "http" + url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}" + + return url + + def save_file(self, name: str, content: bytes) -> None: + """Save file to S3.""" + self.client.put_object( + Bucket=self.bucket_name, + Key=f"{self.base_path}/{name}", + Body=content, + ACL="private", + ) + + @contextmanager + def save_file_stream(self, name: str) -> Iterator: + """Context manager for streaming file writes to S3.""" + # Keep files in memory up to 5 MB + with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file: + yield file + file.seek(0) + self.client.upload_fileobj( + Fileobj=file, + Bucket=self.bucket_name, + Key=f"{self.base_path}/{name}", + ExtraArgs={ + "ACL": "private", + }, + ) + + def delete_file(self, name: str) -> None: + """Delete file from S3.""" + self.client.delete_object( + Bucket=self.bucket_name, + Key=f"{self.base_path}/{name}", + ) + + def file_exists(self, name: str) -> bool: + """Check if a file exists in S3.""" + try: + self.client.head_object( + Bucket=self.bucket_name, + Key=f"{self.base_path}/{name}", + ) + return True + except ClientError: + return False diff --git a/authentik/admin/files/backends/static.py b/authentik/admin/files/backends/static.py new file mode 100644 index 0000000000..54d91daf28 --- /dev/null +++ b/authentik/admin/files/backends/static.py @@ -0,0 +1,53 @@ +from collections.abc import Generator +from pathlib import Path + +from django.http.request import HttpRequest + +from authentik.admin.files.backends.base import Backend +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG + +STATIC_ASSETS_BASE_DIR = Path("web/dist") +STATIC_ASSETS_DIRS = [Path(p) for p in ("assets/icons", "assets/images")] +STATIC_ASSETS_SOURCES_DIR = Path("web/authentik/sources") +STATIC_FILE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg"] +STATIC_PATH_PREFIX = "/static" + + +class StaticBackend(Backend): + """Read-only backend for static files from web/dist/assets. + + - Used for serving built-in static assets like icons and images. + - Files cannot be uploaded or deleted through this backend. + - Only accessible through resolve_file_url when a static path is detected. + """ + + allowed_usages = [FileUsage.MEDIA] + + def supports_file(self, name: str) -> bool: + """Check if file path is a static path.""" + return name.startswith(STATIC_PATH_PREFIX) + + def list_files(self) -> Generator[str]: + """List all static files.""" + # List built-in source icons + if STATIC_ASSETS_SOURCES_DIR.exists(): + for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir(): + if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS): + yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}" + + # List other static assets + for dir in STATIC_ASSETS_DIRS: + dist_dir = STATIC_ASSETS_BASE_DIR / dir + if dist_dir.exists(): + for file_path in dist_dir.rglob("*"): + if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS): + yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}" + + def file_url(self, name: str, request: HttpRequest | None = None) -> str: + """Get URL for static file.""" + prefix = CONFIG.get("web.path", "/")[:-1] + url = f"{prefix}{name}" + if request is None: + return url + return request.build_absolute_uri(url) diff --git a/authentik/admin/files/backends/tests/__init__.py b/authentik/admin/files/backends/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/admin/files/backends/tests/test_file_backend.py b/authentik/admin/files/backends/tests/test_file_backend.py new file mode 100644 index 0000000000..1c0f87d170 --- /dev/null +++ b/authentik/admin/files/backends/tests/test_file_backend.py @@ -0,0 +1,167 @@ +from pathlib import Path + +from django.test import TestCase + +from authentik.admin.files.backends.file import FileBackend +from authentik.admin.files.tests.utils import FileTestFileBackendMixin +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG + + +class TestFileBackend(FileTestFileBackendMixin, TestCase): + """Test FileBackend class""" + + def setUp(self): + """Set up test fixtures""" + super().setUp() + self.backend = FileBackend(FileUsage.MEDIA) + + def test_allowed_usages(self): + """Test that FileBackend supports all usage types""" + self.assertEqual(self.backend.allowed_usages, list(FileUsage)) + + def test_base_path(self): + """Test base_path property constructs correct path""" + base_path = self.backend.base_path + + expected = Path(self.media_backend_path) / "media" / "public" + self.assertEqual(base_path, expected) + + def test_base_path_reports_usage(self): + """Test base_path with reports usage""" + backend = FileBackend(FileUsage.REPORTS) + base_path = backend.base_path + + expected = Path(self.reports_backend_path) / "reports" / "public" + self.assertEqual(base_path, expected) + + def test_list_files_empty_directory(self): + """Test list_files returns empty when directory is empty""" + # Create the directory but keep it empty + self.backend.base_path.mkdir(parents=True, exist_ok=True) + + files = list(self.backend.list_files()) + self.assertEqual(files, []) + + def test_list_files_with_files(self): + """Test list_files returns all files in directory""" + base_path = self.backend.base_path + base_path.mkdir(parents=True, exist_ok=True) + + # Create some test files + (base_path / "file1.txt").write_text("content1") + (base_path / "file2.png").write_text("content2") + (base_path / "subdir").mkdir() + (base_path / "subdir" / "file3.csv").write_text("content3") + + files = sorted(list(self.backend.list_files())) + expected = sorted(["file1.txt", "file2.png", "subdir/file3.csv"]) + self.assertEqual(files, expected) + + def test_list_files_nonexistent_directory(self): + """Test list_files returns empty when directory doesn't exist""" + files = list(self.backend.list_files()) + self.assertEqual(files, []) + + def test_save_file(self): + content = b"test file content" + file_name = "test.txt" + + self.backend.save_file(file_name, content) + + # Verify file was created + file_path = self.backend.base_path / file_name + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), content) + + def test_save_file_creates_subdirectories(self): + """Test save_file creates parent directories as needed""" + content = b"nested file content" + file_name = "subdir1/subdir2/nested.txt" + + self.backend.save_file(file_name, content) + + # Verify file and directories were created + file_path = self.backend.base_path / file_name + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), content) + + def test_save_file_stream(self): + """Test save_file_stream context manager writes file correctly""" + content = b"streamed content" + file_name = "stream_test.txt" + + with self.backend.save_file_stream(file_name) as f: + f.write(content) + + # Verify file was created + file_path = self.backend.base_path / file_name + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), content) + + def test_save_file_stream_creates_subdirectories(self): + """Test save_file_stream creates parent directories as needed""" + content = b"nested stream content" + file_name = "dir1/dir2/stream.bin" + + with self.backend.save_file_stream(file_name) as f: + f.write(content) + + # Verify file and directories were created + file_path = self.backend.base_path / file_name + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), content) + + def test_delete_file(self): + """Test delete_file removes existing file""" + file_name = "to_delete.txt" + + # Create file first + self.backend.save_file(file_name, b"content") + file_path = self.backend.base_path / file_name + self.assertTrue(file_path.exists()) + + # Delete it + self.backend.delete_file(file_name) + self.assertFalse(file_path.exists()) + + def test_delete_file_nonexistent(self): + """Test delete_file handles nonexistent file gracefully""" + file_name = "does_not_exist.txt" + self.backend.delete_file(file_name) + + def test_file_url(self): + """Test file_url generates correct URL""" + file_name = "icon.png" + + url = self.backend.file_url(file_name).split("?")[0] + expected = "/files/media/public/icon.png" + self.assertEqual(url, expected) + + @CONFIG.patch("web.path", "/authentik/") + def test_file_url_with_prefix(self): + """Test file_url with web path prefix""" + file_name = "logo.svg" + + url = self.backend.file_url(file_name).split("?")[0] + expected = "/authentik/files/media/public/logo.svg" + self.assertEqual(url, expected) + + def test_file_url_nested_path(self): + """Test file_url with nested file path""" + file_name = "path/to/file.png" + + url = self.backend.file_url(file_name).split("?")[0] + expected = "/files/media/public/path/to/file.png" + self.assertEqual(url, expected) + + def test_file_exists_true(self): + """Test file_exists returns True for existing file""" + file_name = "exists.txt" + self.backend.base_path.mkdir(parents=True, exist_ok=True) + (self.backend.base_path / file_name).touch() + self.assertTrue(self.backend.file_exists(file_name)) + + def test_file_exists_false(self): + """Test file_exists returns False for nonexistent file""" + self.assertFalse(self.backend.file_exists("does_not_exist.txt")) diff --git a/authentik/admin/files/backends/tests/test_passthrough_backend.py b/authentik/admin/files/backends/tests/test_passthrough_backend.py new file mode 100644 index 0000000000..71709a104b --- /dev/null +++ b/authentik/admin/files/backends/tests/test_passthrough_backend.py @@ -0,0 +1,67 @@ +"""Test passthrough backend""" + +from django.test import TestCase + +from authentik.admin.files.backends.passthrough import PassthroughBackend +from authentik.admin.files.usage import FileUsage + + +class TestPassthroughBackend(TestCase): + """Test PassthroughBackend class""" + + def setUp(self): + """Set up test fixtures""" + self.backend = PassthroughBackend(FileUsage.MEDIA) + + def test_allowed_usages(self): + """Test that PassthroughBackend only supports MEDIA usage""" + self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA]) + + def test_supports_file_path_font_awesome(self): + """Test supports_file_path returns True for Font Awesome icons""" + self.assertTrue(self.backend.supports_file("fa://user")) + self.assertTrue(self.backend.supports_file("fa://home")) + self.assertTrue(self.backend.supports_file("fa://shield")) + + def test_supports_file_path_http(self): + """Test supports_file_path returns True for HTTP URLs""" + self.assertTrue(self.backend.supports_file("http://example.com/icon.png")) + self.assertTrue(self.backend.supports_file("http://cdn.example.com/logo.svg")) + + def test_supports_file_path_https(self): + """Test supports_file_path returns True for HTTPS URLs""" + self.assertTrue(self.backend.supports_file("https://example.com/icon.png")) + self.assertTrue(self.backend.supports_file("https://cdn.example.com/logo.svg")) + + def test_supports_file_path_false(self): + """Test supports_file_path returns False for regular paths""" + self.assertFalse(self.backend.supports_file("icon.png")) + self.assertFalse(self.backend.supports_file("/static/icon.png")) + self.assertFalse(self.backend.supports_file("media/logo.svg")) + self.assertFalse(self.backend.supports_file("")) + + def test_supports_file_path_invalid_scheme(self): + """Test supports_file_path returns False for invalid schemes""" + self.assertFalse(self.backend.supports_file("ftp://example.com/file.png")) + self.assertFalse(self.backend.supports_file("file:///path/to/file.png")) + self.assertFalse(self.backend.supports_file("data:image/png;base64,abc123")) + + def test_list_files(self): + """Test list_files returns empty generator""" + files = list(self.backend.list_files()) + self.assertEqual(files, []) + + def test_file_url(self): + """Test file_url returns the URL as-is""" + url = "https://example.com/icon.png" + self.assertEqual(self.backend.file_url(url), url) + + def test_file_url_font_awesome(self): + """Test file_url returns Font Awesome URL as-is""" + url = "fa://user" + self.assertEqual(self.backend.file_url(url), url) + + def test_file_url_http(self): + """Test file_url returns HTTP URL as-is""" + url = "http://cdn.example.com/logo.svg" + self.assertEqual(self.backend.file_url(url), url) diff --git a/authentik/admin/files/backends/tests/test_s3_backend.py b/authentik/admin/files/backends/tests/test_s3_backend.py new file mode 100644 index 0000000000..6f41abf4ab --- /dev/null +++ b/authentik/admin/files/backends/tests/test_s3_backend.py @@ -0,0 +1,109 @@ +from django.test import TestCase + +from authentik.admin.files.tests.utils import FileTestS3BackendMixin +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG + + +class TestS3Backend(FileTestS3BackendMixin, TestCase): + """Test S3 backend functionality""" + + def setUp(self): + super().setUp() + + def test_base_path(self): + """Test base_path property generates correct S3 key prefix""" + expected = "media/public" + self.assertEqual(self.media_s3_backend.base_path, expected) + + def test_supports_file_path_s3(self): + """Test supports_file_path returns True for s3 backend""" + self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png")) + self.assertTrue(self.media_s3_backend.supports_file("any-file.png")) + + def test_list_files(self): + """Test list_files returns relative paths""" + self.media_s3_backend.client.put_object( + Bucket=self.media_s3_bucket_name, + Key="media/public/file1.png", + Body=b"test content", + ACL="private", + ) + self.media_s3_backend.client.put_object( + Bucket=self.media_s3_bucket_name, + Key="media/other/file1.png", + Body=b"test content", + ACL="private", + ) + + files = list(self.media_s3_backend.list_files()) + + self.assertEqual(len(files), 1) + self.assertIn("file1.png", files) + + def test_list_files_empty(self): + """Test list_files with no files""" + files = list(self.media_s3_backend.list_files()) + + self.assertEqual(len(files), 0) + + def test_save_file(self): + """Test save_file uploads to S3""" + content = b"test file content" + self.media_s3_backend.save_file("test.png", content) + + def test_save_file_stream(self): + """Test save_file_stream uploads to S3 using context manager""" + with self.media_s3_backend.save_file_stream("test.csv") as f: + f.write(b"header1,header2\n") + f.write(b"value1,value2\n") + + def test_delete_file(self): + """Test delete_file removes from S3""" + self.media_s3_backend.client.put_object( + Bucket=self.media_s3_bucket_name, + Key="media/public/test.png", + Body=b"test content", + ACL="private", + ) + self.media_s3_backend.delete_file("test.png") + + @CONFIG.patch("storage.s3.secure_urls", True) + @CONFIG.patch("storage.s3.custom_domain", None) + def test_file_url_basic(self): + """Test file_url generates presigned URL with AWS signature format""" + url = self.media_s3_backend.file_url("test.png") + + self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url) + self.assertIn("X-Amz-Signature=", url) + self.assertIn("test.png", url) + + @CONFIG.patch("storage.s3.bucket_name", "test-bucket") + def test_file_exists_true(self): + """Test file_exists returns True for existing file""" + self.media_s3_backend.client.put_object( + Bucket=self.media_s3_bucket_name, + Key="media/public/test.png", + Body=b"test content", + ACL="private", + ) + + exists = self.media_s3_backend.file_exists("test.png") + + self.assertTrue(exists) + + @CONFIG.patch("storage.s3.bucket_name", "test-bucket") + def test_file_exists_false(self): + """Test file_exists returns False for non-existent file""" + exists = self.media_s3_backend.file_exists("nonexistent.png") + + self.assertFalse(exists) + + def test_allowed_usages(self): + """Test that S3Backend supports all usage types""" + self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage)) + + def test_reports_usage(self): + """Test S3Backend with REPORTS usage""" + self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS) + self.assertEqual(self.reports_s3_backend.base_path, "reports/public") diff --git a/authentik/admin/files/backends/tests/test_static_backend.py b/authentik/admin/files/backends/tests/test_static_backend.py new file mode 100644 index 0000000000..4bff77d59e --- /dev/null +++ b/authentik/admin/files/backends/tests/test_static_backend.py @@ -0,0 +1,42 @@ +from django.test import TestCase + +from authentik.admin.files.backends.static import StaticBackend +from authentik.admin.files.usage import FileUsage + + +class TestStaticBackend(TestCase): + """Test Static backend functionality""" + + def setUp(self): + """Set up test fixtures""" + self.usage = FileUsage.MEDIA + self.backend = StaticBackend(self.usage) + + def test_init(self): + """Test StaticBackend initialization""" + self.assertEqual(self.backend.usage, self.usage) + + def test_allowed_usages(self): + """Test that StaticBackend only supports MEDIA usage""" + self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA]) + + def test_supports_file_path_static_prefix(self): + """Test supports_file_path returns True for /static prefix""" + self.assertTrue(self.backend.supports_file("/static/assets/icons/test.svg")) + self.assertTrue(self.backend.supports_file("/static/authentik/sources/icon.png")) + + def test_supports_file_path_not_static(self): + """Test supports_file_path returns False for non-static paths""" + self.assertFalse(self.backend.supports_file("web/dist/assets/icons/test.svg")) + self.assertFalse(self.backend.supports_file("web/dist/assets/images/logo.png")) + self.assertFalse(self.backend.supports_file("media/public/test.png")) + self.assertFalse(self.backend.supports_file("/media/test.svg")) + self.assertFalse(self.backend.supports_file("test.jpg")) + + def test_list_files(self): + """Test list_files includes expected files""" + files = list(self.backend.list_files()) + + self.assertIn("/static/authentik/sources/ldap.png", files) + self.assertIn("/static/authentik/sources/openidconnect.svg", files) + self.assertIn("/static/authentik/sources/saml.png", files) diff --git a/authentik/admin/files/fields.py b/authentik/admin/files/fields.py new file mode 100644 index 0000000000..5f5c952d9f --- /dev/null +++ b/authentik/admin/files/fields.py @@ -0,0 +1,7 @@ +from django.db import models + +from authentik.admin.files.validation import validate_file_name + + +class FileField(models.TextField): + default_validators = [validate_file_name] diff --git a/authentik/admin/files/manager.py b/authentik/admin/files/manager.py new file mode 100644 index 0000000000..9413a7fb7f --- /dev/null +++ b/authentik/admin/files/manager.py @@ -0,0 +1,141 @@ +from collections.abc import Generator, Iterator + +from django.core.exceptions import ImproperlyConfigured +from django.http.request import HttpRequest +from rest_framework.request import Request +from structlog.stdlib import get_logger + +from authentik.admin.files.backends.base import ManageableBackend +from authentik.admin.files.backends.file import FileBackend +from authentik.admin.files.backends.passthrough import PassthroughBackend +from authentik.admin.files.backends.s3 import S3Backend +from authentik.admin.files.backends.static import StaticBackend +from authentik.admin.files.usage import FileUsage +from authentik.lib.config import CONFIG + +LOGGER = get_logger() + + +_FILE_BACKENDS = [ + StaticBackend, + PassthroughBackend, + FileBackend, + S3Backend, +] + + +class FileManager: + def __init__(self, usage: FileUsage) -> None: + management_backend_name = CONFIG.get( + f"storage.{usage.value}.backend", + CONFIG.get("storage.backend", "file"), + ) + + self.management_backend = None + for backend in _FILE_BACKENDS: + if issubclass(backend, ManageableBackend) and backend.name == management_backend_name: + self.management_backend = backend(usage) + if self.management_backend is None: + LOGGER.warning( + f"Storage backend configuration for {usage.value} is " + f"invalid: {management_backend_name}" + ) + + self.backends = [] + for backend in _FILE_BACKENDS: + if usage not in backend.allowed_usages: + continue + if isinstance(self.management_backend, backend): + self.backends.append(self.management_backend) + elif not issubclass(backend, ManageableBackend): + self.backends.append(backend(usage)) + + @property + def manageable(self) -> bool: + """ + Whether this file manager is able to manage files. + """ + return self.management_backend is not None and self.management_backend.manageable + + def list_files(self, manageable_only: bool = False) -> Generator[str]: + """ + List available files. + """ + for backend in self.backends: + if manageable_only and not isinstance(backend, ManageableBackend): + continue + yield from backend.list_files() + + def file_url( + self, + name: str | None, + request: HttpRequest | Request | None = None, + ) -> str: + """ + Get URL for accessing the file. + """ + if not name: + return "" + + if isinstance(request, Request): + request = request._request + + for backend in self.backends: + if backend.supports_file(name): + return backend.file_url(name, request) + + LOGGER.warning(f"Could not find file backend for file: {name}") + return "" + + def _check_manageable(self) -> None: + if not self.manageable: + raise ImproperlyConfigured("No file management backend configured.") + + def save_file(self, file_path: str, content: bytes) -> None: + """ + Save file contents to storage. + """ + self._check_manageable() + assert self.management_backend is not None # nosec + return self.management_backend.save_file(file_path, content) + + def save_file_stream(self, file_path: str) -> Iterator: + """ + Context manager for streaming file writes. + + Args: + file_path: Relative file path + + Returns: + Context manager that yields a writable file-like object + + Usage: + with manager.save_file_stream("output.csv") as f: + f.write(b"data...") + """ + self._check_manageable() + assert self.management_backend is not None # nosec + return self.management_backend.save_file_stream(file_path) + + def delete_file(self, file_path: str) -> None: + """ + Delete file from storage. + """ + self._check_manageable() + assert self.management_backend is not None # nosec + return self.management_backend.delete_file(file_path) + + def file_exists(self, file_path: str) -> bool: + """ + Check if a file exists. + """ + self._check_manageable() + assert self.management_backend is not None # nosec + return self.management_backend.file_exists(file_path) + + +MANAGERS = {usage: FileManager(usage) for usage in list(FileUsage)} + + +def get_file_manager(usage: FileUsage) -> FileManager: + return MANAGERS[usage] diff --git a/authentik/admin/files/tests/__init__.py b/authentik/admin/files/tests/__init__.py new file mode 100644 index 0000000000..759327e113 --- /dev/null +++ b/authentik/admin/files/tests/__init__.py @@ -0,0 +1 @@ +"""authentik files tests""" diff --git a/authentik/admin/files/tests/test_api.py b/authentik/admin/files/tests/test_api.py new file mode 100644 index 0000000000..584def49b9 --- /dev/null +++ b/authentik/admin/files/tests/test_api.py @@ -0,0 +1,229 @@ +"""test file api""" + +from io import BytesIO + +from django.test import TestCase +from django.urls import reverse + +from authentik.admin.files.api import get_mime_from_filename +from authentik.admin.files.manager import FileManager +from authentik.admin.files.tests.utils import FileTestFileBackendMixin +from authentik.admin.files.usage import FileUsage +from authentik.core.tests.utils import create_test_admin_user +from authentik.events.models import Event, EventAction + + +class TestFileAPI(FileTestFileBackendMixin, TestCase): + """test file api""" + + def setUp(self) -> None: + super().setUp() + self.user = create_test_admin_user() + self.client.force_login(self.user) + + def test_upload_creates_event(self): + """Test that uploading a file creates a FILE_UPLOADED event""" + manager = FileManager(FileUsage.MEDIA) + file_content = b"test file content" + file_name = "test-upload.png" + + # Upload file + response = self.client.post( + reverse("authentik_api:files"), + { + "file": BytesIO(file_content), + "name": file_name, + "usage": FileUsage.MEDIA.value, + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 200) + + # Verify event was created + event = Event.objects.filter(action=EventAction.MODEL_CREATED).first() + + self.assertIsNotNone(event) + assert event is not None # nosec + self.assertEqual(event.context["model"]["name"], file_name) + self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value) + self.assertEqual(event.context["model"]["mime_type"], "image/png") + + # Verify user is captured + self.assertEqual(event.user["username"], self.user.username) + self.assertEqual(event.user["pk"], self.user.pk) + + manager.delete_file(file_name) + + def test_delete_creates_event(self): + """Test that deleting a file creates an event""" + manager = FileManager(FileUsage.MEDIA) + file_name = "test-delete.png" + manager.save_file(file_name, b"test content") + + # Delete file + response = self.client.delete( + reverse( + "authentik_api:files", + query={ + "name": file_name, + "usage": FileUsage.MEDIA.value, + }, + ) + ) + + self.assertEqual(response.status_code, 200) + + # Verify event was created + event = Event.objects.filter(action=EventAction.MODEL_DELETED).first() + + self.assertIsNotNone(event) + assert event is not None # nosec + self.assertEqual(event.context["model"]["name"], file_name) + self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value) + + # Verify user is captured + self.assertEqual(event.user["username"], self.user.username) + self.assertEqual(event.user["pk"], self.user.pk) + + def test_list_files_basic(self): + """Test listing files with default parameters""" + response = self.client.get(reverse("authentik_api:files")) + + self.assertEqual(response.status_code, 200) + self.assertIn( + { + "name": "/static/authentik/sources/ldap.png", + "url": "/static/authentik/sources/ldap.png", + "mime_type": "image/png", + }, + response.data, + ) + + def test_list_files_invalid_usage(self): + """Test listing files with invalid usage parameter""" + response = self.client.get( + reverse( + "authentik_api:files", + query={ + "usage": "invalid", + }, + ) + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("not a valid choice", str(response.data)) + + def test_list_files_with_search(self): + """Test listing files with search query""" + response = self.client.get( + reverse( + "authentik_api:files", + query={ + "search": "ldap.png", + }, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertIn( + { + "name": "/static/authentik/sources/ldap.png", + "url": "/static/authentik/sources/ldap.png", + "mime_type": "image/png", + }, + response.data, + ) + + def test_list_files_with_manageable_only(self): + """Test listing files with omit parameter""" + response = self.client.get( + reverse( + "authentik_api:files", + query={ + "manageableOnly": "true", + }, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertNotIn( + { + "name": "/static/dist/assets/images/flow_background.jpg", + "mime_type": "image/jpeg", + }, + response.data, + ) + + def test_upload_file_with_custom_path(self): + """Test uploading file with custom path""" + manager = FileManager(FileUsage.MEDIA) + file_name = "custom/test" + file_content = b"test content" + response = self.client.post( + reverse("authentik_api:files"), + { + "file": BytesIO(file_content), + "name": file_name, + "usage": FileUsage.MEDIA.value, + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(manager.file_exists(file_name)) + manager.delete_file(file_name) + + def test_upload_file_duplicate(self): + """Test uploading file that already exists""" + manager = FileManager(FileUsage.MEDIA) + file_name = "test-file.png" + file_content = b"test content" + manager.save_file(file_name, file_content) + response = self.client.post( + reverse("authentik_api:files"), + { + "file": BytesIO(file_content), + "name": file_name, + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("already exists", str(response.data)) + manager.delete_file(file_name) + + def test_delete_without_name_parameter(self): + """Test delete without name parameter""" + response = self.client.delete(reverse("authentik_api:files")) + + self.assertEqual(response.status_code, 400) + self.assertIn("field is required", str(response.data)) + + +class TestGetMimeFromFilename(TestCase): + """Test get_mime_from_filename function""" + + def test_image_png(self): + """Test PNG image MIME type""" + self.assertEqual(get_mime_from_filename("test.png"), "image/png") + + def test_image_jpeg(self): + """Test JPEG image MIME type""" + self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg") + + def test_image_svg(self): + """Test SVG image MIME type""" + self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml") + + def test_text_plain(self): + """Test text file MIME type""" + self.assertEqual(get_mime_from_filename("test.txt"), "text/plain") + + def test_unknown_extension(self): + """Test unknown extension returns octet-stream""" + self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream") + + def test_no_extension(self): + """Test no extension returns octet-stream""" + self.assertEqual(get_mime_from_filename("test"), "application/octet-stream") diff --git a/authentik/admin/files/tests/test_manager.py b/authentik/admin/files/tests/test_manager.py new file mode 100644 index 0000000000..d2f0cca685 --- /dev/null +++ b/authentik/admin/files/tests/test_manager.py @@ -0,0 +1,99 @@ +"""Test file service layer""" + +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.usage import FileUsage +from authentik.lib.config import CONFIG + + +class TestResolveFileUrlBasic(TestCase): + def test_resolve_empty_path(self): + """Test resolving empty file path""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("") + self.assertEqual(result, "") + + def test_resolve_none_path(self): + """Test resolving None file path""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url(None) + self.assertEqual(result, "") + + def test_resolve_font_awesome(self): + """Test resolving Font Awesome icon""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("fa://fa-check") + self.assertEqual(result, "fa://fa-check") + + def test_resolve_http_url(self): + """Test resolving HTTP URL""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("http://example.com/icon.png") + self.assertEqual(result, "http://example.com/icon.png") + + def test_resolve_https_url(self): + """Test resolving HTTPS URL""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("https://example.com/icon.png") + self.assertEqual(result, "https://example.com/icon.png") + + def test_resolve_static_path(self): + """Test resolving static file path""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("/static/authentik/sources/icon.svg") + self.assertEqual(result, "/static/authentik/sources/icon.svg") + + +class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase): + def test_resolve_storage_file(self): + """Test resolving uploaded storage file""" + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("test.png").split("?")[0] + self.assertEqual(result, "/files/media/public/test.png") + + def test_resolve_full_static_with_request(self): + """Test resolving static file with request builds absolute URI""" + mock_request = HttpRequest() + mock_request.META = { + "HTTP_HOST": "example.com", + "SERVER_NAME": "example.com", + } + + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("/static/icon.svg", mock_request) + + self.assertEqual(result, "http://example.com/static/icon.svg") + + def test_resolve_full_file_backend_with_request(self): + """Test resolving FileBackend file with request""" + mock_request = HttpRequest() + mock_request.META = { + "HTTP_HOST": "example.com", + "SERVER_NAME": "example.com", + } + + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("test.png", mock_request).split("?")[0] + + self.assertEqual(result, "http://example.com/files/media/public/test.png") + + +class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase): + @CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test") + @CONFIG.patch("storage.media.s3.secure_urls", False) + def test_resolve_full_s3_backend(self): + """Test resolving S3Backend returns presigned URL as-is""" + mock_request = HttpRequest() + mock_request.META = { + "HTTP_HOST": "example.com", + "SERVER_NAME": "example.com", + } + + manager = FileManager(FileUsage.MEDIA) + result = manager.file_url("test.png", mock_request) + + # S3 URLs should be returned as-is (already absolute) + self.assertTrue(result.startswith("http://s3.test:8080/test")) diff --git a/authentik/admin/files/tests/test_validation.py b/authentik/admin/files/tests/test_validation.py new file mode 100644 index 0000000000..1c575f2d03 --- /dev/null +++ b/authentik/admin/files/tests/test_validation.py @@ -0,0 +1,110 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from authentik.admin.files.validation import ( + MAX_FILE_NAME_LENGTH, + MAX_PATH_COMPONENT_LENGTH, + validate_file_name, +) + + +class TestSanitizeFilePath(TestCase): + """Test validate_file_name function""" + + def test_sanitize_valid_filename(self): + """Test sanitizing valid filename""" + validate_file_name("test.png") + + def test_sanitize_valid_path_with_directory(self): + """Test sanitizing valid path with directory""" + validate_file_name("images/test.png") + + def test_sanitize_valid_path_with_nested_dirs(self): + """Test sanitizing valid path with nested directories""" + validate_file_name("dir1/dir2/dir3/test.png") + + def test_sanitize_with_hyphens(self): + """Test sanitizing filename with hyphens""" + validate_file_name("test-file-name.png") + + def test_sanitize_with_underscores(self): + """Test sanitizing filename with underscores""" + validate_file_name("test_file_name.png") + + def test_sanitize_with_dots(self): + """Test sanitizing filename with multiple dots""" + validate_file_name("test.file.name.png") + + def test_sanitize_strips_whitespace(self): + """Test sanitizing filename strips whitespace""" + with self.assertRaises(ValidationError): + validate_file_name(" test.png ") + + def test_sanitize_removes_duplicate_slashes(self): + """Test sanitizing path removes duplicate slashes""" + with self.assertRaises(ValidationError): + validate_file_name("dir1//dir2///test.png") + + def test_sanitize_empty_path_raises(self): + """Test sanitizing empty path raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name("") + + def test_sanitize_whitespace_only_raises(self): + """Test sanitizing whitespace-only path raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name(" ") + + def test_sanitize_invalid_characters_raises(self): + """Test sanitizing path with invalid characters raises ValidationError""" + invalid_paths = [ + "test file.png", # space + "test@file.png", # @ + "test#file.png", # # + "test$file.png", # $ + "test%file.png", # % + "test&file.png", # & + "test*file.png", # * + "test(file).png", # parentheses + "test[file].png", # brackets + "test{file}.png", # braces + ] + + for path in invalid_paths: + with self.assertRaises(ValidationError): + validate_file_name(path) + + def test_sanitize_absolute_path_raises(self): + """Test sanitizing absolute path raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name("/absolute/path/test.png") + + def test_sanitize_parent_directory_raises(self): + """Test sanitizing path with parent directory reference raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name("../test.png") + + def test_sanitize_nested_parent_directory_raises(self): + """Test sanitizing path with nested parent directory reference raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name("dir1/../test.png") + + def test_sanitize_starts_with_dot_raises(self): + """Test sanitizing path starting with dot raises ValidationError""" + with self.assertRaises(ValidationError): + validate_file_name(".hidden") + + def test_sanitize_too_long_path_raises(self): + """Test sanitizing too long path raises ValidationError""" + long_path = "a" * (MAX_FILE_NAME_LENGTH + 1) + ".png" + + with self.assertRaises(ValidationError): + validate_file_name(long_path) + + def test_sanitize_too_long_component_raises(self): + """Test sanitizing path with too long component raises ValidationError""" + long_component = "a" * (MAX_PATH_COMPONENT_LENGTH + 1) + path = f"dir/{long_component}.png" + + with self.assertRaises(ValidationError): + validate_file_name(path) diff --git a/authentik/admin/files/tests/utils.py b/authentik/admin/files/tests/utils.py new file mode 100644 index 0000000000..1e75474b4b --- /dev/null +++ b/authentik/admin/files/tests/utils.py @@ -0,0 +1,114 @@ +import shutil +from tempfile import mkdtemp + +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 + + +class FileTestFileBackendMixin: + def setUp(self): + self.original_media_backend = CONFIG.get("storage.media.backend", UNSET) + self.original_media_backend_path = CONFIG.get("storage.media.file.path", UNSET) + self.media_backend_path = mkdtemp() + CONFIG.set("storage.media.backend", "file") + CONFIG.set("storage.media.file.path", str(self.media_backend_path)) + + self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET) + self.original_reports_backend_path = CONFIG.get("storage.reports.file.path", UNSET) + self.reports_backend_path = mkdtemp() + CONFIG.set("storage.reports.backend", "file") + CONFIG.set("storage.reports.file.path", str(self.reports_backend_path)) + + def tearDown(self): + if self.original_media_backend is not UNSET: + CONFIG.set("storage.media.backend", self.original_media_backend) + else: + CONFIG.delete("storage.media.backend") + if self.original_media_backend_path is not UNSET: + CONFIG.set("storage.media.file.path", self.original_media_backend_path) + else: + CONFIG.delete("storage.media.file.path") + shutil.rmtree(self.media_backend_path) + + if self.original_reports_backend is not UNSET: + CONFIG.set("storage.reports.backend", self.original_reports_backend) + else: + CONFIG.delete("storage.reports.backend") + if self.original_reports_backend_path is not UNSET: + CONFIG.set("storage.reports.file.path", self.original_reports_backend_path) + else: + CONFIG.delete("storage.reports.file.path") + shutil.rmtree(self.reports_backend_path) + + +class FileTestS3BackendMixin: + def setUp(self): + s3_config_keys = { + "endpoint", + "access_key", + "secret_key", + "bucket_name", + } + self.original_media_backend = CONFIG.get("storage.media.backend", UNSET) + CONFIG.set("storage.media.backend", "s3") + self.original_media_s3_settings = {} + 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.access_key", "accessKey1") + CONFIG.set("storage.media.s3.secret_key", "secretKey1") + CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name) + self.media_s3_backend = S3Backend(FileUsage.MEDIA) + self.media_s3_backend.client.create_bucket(Bucket=self.media_s3_bucket_name, ACL="private") + + self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET) + CONFIG.set("storage.reports.backend", "s3") + self.original_reports_s3_settings = {} + 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.access_key", "accessKey1") + CONFIG.set("storage.reports.s3.secret_key", "secretKey1") + CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name) + self.reports_s3_backend = S3Backend(FileUsage.REPORTS) + self.reports_s3_backend.client.create_bucket( + Bucket=self.reports_s3_bucket_name, ACL="private" + ) + + def tearDown(self): + def delete_objects_in_bucket(client, bucket_name): + paginator = client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=bucket_name) + for page in pages: + if "Contents" not in page: + continue + for obj in page["Contents"]: + client.delete_object(Bucket=bucket_name, Key=obj["Key"]) + + delete_objects_in_bucket(self.media_s3_backend.client, self.media_s3_bucket_name) + self.media_s3_backend.client.delete_bucket(Bucket=self.media_s3_bucket_name) + if self.original_media_backend is not UNSET: + CONFIG.set("storage.media.backend", self.original_media_backend) + else: + CONFIG.delete("storage.media.backend") + for k, v in self.original_media_s3_settings.items(): + if v is not UNSET: + CONFIG.set(f"storage.media.s3.{k}", v) + else: + CONFIG.delete(f"storage.media.s3.{k}") + + delete_objects_in_bucket(self.reports_s3_backend.client, self.reports_s3_bucket_name) + self.reports_s3_backend.client.delete_bucket(Bucket=self.reports_s3_bucket_name) + if self.original_reports_backend is not UNSET: + CONFIG.set("storage.reports.backend", self.original_reports_backend) + else: + CONFIG.delete("storage.reports.backend") + for k, v in self.original_reports_s3_settings.items(): + if v is not UNSET: + CONFIG.set(f"storage.reports.s3.{k}", v) + else: + CONFIG.delete(f"storage.reports.s3.{k}") diff --git a/authentik/admin/files/urls.py b/authentik/admin/files/urls.py new file mode 100644 index 0000000000..c970deec34 --- /dev/null +++ b/authentik/admin/files/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from authentik.admin.files.api import FileUsedByView, FileView + +api_urlpatterns = [ + path("admin/file/", FileView.as_view(), name="files"), + path("admin/file/used_by/", FileUsedByView.as_view(), name="files-used-by"), +] diff --git a/authentik/admin/files/usage.py b/authentik/admin/files/usage.py new file mode 100644 index 0000000000..00dc18fbdb --- /dev/null +++ b/authentik/admin/files/usage.py @@ -0,0 +1,17 @@ +from enum import StrEnum +from itertools import chain + + +class FileApiUsage(StrEnum): + """Usage types for file API""" + + MEDIA = "media" + + +class FileManagedUsage(StrEnum): + """Usage types for managed files""" + + REPORTS = "reports" + + +FileUsage = StrEnum("FileUsage", [(v.name, v.value) for v in chain(FileApiUsage, FileManagedUsage)]) diff --git a/authentik/admin/files/validation.py b/authentik/admin/files/validation.py new file mode 100644 index 0000000000..6da0fa8020 --- /dev/null +++ b/authentik/admin/files/validation.py @@ -0,0 +1,79 @@ +import re +from pathlib import PurePosixPath + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +from authentik.admin.files.backends.passthrough import PassthroughBackend +from authentik.admin.files.backends.static import StaticBackend +from authentik.admin.files.usage import FileUsage + +# File upload limits +MAX_FILE_NAME_LENGTH = 1024 +MAX_PATH_COMPONENT_LENGTH = 255 + + +def validate_file_name(name: str) -> None: + if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend( + FileUsage.MEDIA + ).supports_file(name): + return + validate_upload_file_name(name) + + +def validate_upload_file_name( + name: str, + ValidationError: type[Exception] = ValidationError, +) -> None: + """Sanitize file path. + + Args: + file_path: The file path to sanitize + + Returns: + Sanitized file path + + Raises: + ValidationError: If file path is invalid + """ + if not name: + raise ValidationError(_("File name cannot be empty")) + + # Same regex is used in the frontend as well + if not re.match(r"^[a-zA-Z0-9._/-]+$", name): + raise ValidationError( + _( + "File name can only contain letters (a-z, A-Z), numbers (0-9), " + "dots (.), hyphens (-), underscores (_), and forward slashes (/)" + ) + ) + + if "//" in name: + raise ValidationError(_("File name cannot contain duplicate /")) + + # Convert to posix path + path = PurePosixPath(name) + + # Check for absolute paths + # Needs the / at the start. If it doesn't have it, it might still be unsafe, so see L53+ + if path.is_absolute(): + raise ValidationError(_("Absolute paths are not allowed")) + + # Check for parent directory references + if ".." in path.parts: + raise ValidationError(_("Parent directory references ('..') are not allowed")) + + # Disallow paths starting with dot (hidden files at root level) + if str(path).startswith("."): + raise ValidationError(_("Paths cannot start with '.'")) + + # Check path length limits + normalized = str(path) + if len(normalized) > MAX_FILE_NAME_LENGTH: + raise ValidationError(_(f"File name too long (max {MAX_FILE_NAME_LENGTH} characters)")) + + for part in path.parts: + if len(part) > MAX_PATH_COMPONENT_LENGTH: + raise ValidationError( + _(f"Path component too long (max {MAX_PATH_COMPONENT_LENGTH} characters)") + ) diff --git a/authentik/api/v3/config.py b/authentik/api/v3/config.py index 47ab0cd34b..91ef377a0d 100644 --- a/authentik/api/v3/config.py +++ b/authentik/api/v3/config.py @@ -1,7 +1,5 @@ """core Configs API""" -from pathlib import Path - from django.conf import settings from django.db import models from django.dispatch import Signal @@ -20,6 +18,8 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileUsage from authentik.core.api.utils import PassiveSerializer from authentik.events.context_processors.base import get_context_processors from authentik.lib.config import CONFIG @@ -68,12 +68,7 @@ class ConfigView(APIView): def get_capabilities(request: HttpRequest) -> list[Capabilities]: """Get all capabilities this server instance supports""" caps = [] - deb_test = settings.DEBUG or settings.TEST - if ( - CONFIG.get("storage.media.backend", "file") == "s3" - or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount() - or deb_test - ): + if get_file_manager(FileUsage.MEDIA).manageable: caps.append(Capabilities.CAN_SAVE_MEDIA) for processor in get_context_processors(): if cap := processor.capability(): diff --git a/authentik/blueprints/tests/test_v1_conditional_fields.py b/authentik/blueprints/tests/test_v1_conditional_fields.py index d260e9599b..d2fc78108b 100644 --- a/authentik/blueprints/tests/test_v1_conditional_fields.py +++ b/authentik/blueprints/tests/test_v1_conditional_fields.py @@ -3,12 +3,10 @@ from django.test import TransactionTestCase from authentik.blueprints.v1.importer import Importer -from authentik.core.models import Application, Token, User +from authentik.core.models import Token, User from authentik.core.tests.utils import create_test_admin_user -from authentik.flows.models import Flow from authentik.lib.generators import generate_id from authentik.lib.tests.utils import load_fixture -from authentik.sources.oauth.models import OAuthSource class TestBlueprintsV1ConditionalFields(TransactionTestCase): @@ -29,24 +27,6 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase): self.assertIsNotNone(token) self.assertEqual(token.key, self.uid) - def test_application(self): - """Test application""" - app = Application.objects.filter(slug=f"{self.uid}-app").first() - self.assertIsNotNone(app) - self.assertEqual(app.meta_icon, "https://goauthentik.io/img/icon.png") - - def test_source(self): - """Test source""" - source = OAuthSource.objects.filter(slug=f"{self.uid}-source").first() - self.assertIsNotNone(source) - self.assertEqual(source.icon, "https://goauthentik.io/img/icon.png") - - def test_flow(self): - """Test flow""" - flow = Flow.objects.filter(slug=f"{self.uid}-flow").first() - self.assertIsNotNone(flow) - self.assertEqual(flow.background, "https://goauthentik.io/img/icon.png") - def test_user(self): """Test user""" user: User = User.objects.filter(username=self.uid).first() diff --git a/authentik/brands/api.py b/authentik/brands/api.py index 2f724ce8f3..723d974511 100644 --- a/authentik/brands/api.py +++ b/authentik/brands/api.py @@ -163,4 +163,4 @@ class BrandViewSet(UsedByMixin, ModelViewSet): def current(self, request: Request) -> Response: """Get current brand""" brand: Brand = request._request.brand - return Response(CurrentBrandSerializer(brand).data) + return Response(CurrentBrandSerializer(brand, context={"request": request}).data) diff --git a/authentik/brands/migrations/0011_alter_brand_branding_default_flow_background_and_more.py b/authentik/brands/migrations/0011_alter_brand_branding_default_flow_background_and_more.py new file mode 100644 index 0000000000..6cb46dab29 --- /dev/null +++ b/authentik/brands/migrations/0011_alter_brand_branding_default_flow_background_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.8 on 2025-11-27 16:22 + +import authentik.admin.files.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_brands", "0010_brand_client_certificates_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="brand", + name="branding_default_flow_background", + field=authentik.admin.files.fields.FileField( + default="/static/dist/assets/images/flow_background.jpg" + ), + ), + migrations.AlterField( + model_name="brand", + name="branding_favicon", + field=authentik.admin.files.fields.FileField( + default="/static/dist/assets/icons/icon.png" + ), + ), + migrations.AlterField( + model_name="brand", + name="branding_logo", + field=authentik.admin.files.fields.FileField( + default="/static/dist/assets/icons/icon_left_brand.svg" + ), + ), + ] diff --git a/authentik/brands/models.py b/authentik/brands/models.py index e9ed2b3072..614d71ae18 100644 --- a/authentik/brands/models.py +++ b/authentik/brands/models.py @@ -8,9 +8,11 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger +from authentik.admin.files.fields import FileField +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileUsage from authentik.crypto.models import CertificateKeyPair from authentik.flows.models import Flow -from authentik.lib.config import CONFIG from authentik.lib.models import SerializerModel LOGGER = get_logger() @@ -31,11 +33,11 @@ class Brand(SerializerModel): branding_title = models.TextField(default="authentik") - branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg") - branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png") + branding_logo = FileField(default="/static/dist/assets/icons/icon_left_brand.svg") + branding_favicon = FileField(default="/static/dist/assets/icons/icon.png") branding_custom_css = models.TextField(default="", blank=True) - branding_default_flow_background = models.TextField( - default="/static/dist/assets/images/flow_background.jpg" + branding_default_flow_background = FileField( + default="/static/dist/assets/images/flow_background.jpg", ) flow_authentication = models.ForeignKey( @@ -84,25 +86,19 @@ class Brand(SerializerModel): attributes = models.JSONField(default=dict, blank=True) def branding_logo_url(self) -> str: - """Get branding_logo with the correct prefix""" - if self.branding_logo.startswith("/static"): - return CONFIG.get("web.path", "/")[:-1] + self.branding_logo - return self.branding_logo + """Get branding_logo URL""" + return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo) def branding_favicon_url(self) -> str: - """Get branding_favicon with the correct prefix""" - if self.branding_favicon.startswith("/static"): - return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon - return self.branding_favicon + """Get branding_favicon URL""" + return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon) def branding_default_flow_background_url(self) -> str: - """Get branding_default_flow_background with the correct prefix""" - if self.branding_default_flow_background.startswith("/static"): - return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background - return self.branding_default_flow_background + """Get branding_default_flow_background URL""" + return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background) @property - def serializer(self) -> Serializer: + def serializer(self) -> type[Serializer]: from authentik.brands.api import BrandSerializer return BrandSerializer diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 2bcc5bf425..1f25d45e8b 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -8,12 +8,11 @@ from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, extend_schema from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField -from rest_framework.parsers import MultiPartParser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -26,16 +25,9 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import ModelSerializer from authentik.core.models import Application, User from authentik.events.logs import LogEventSerializer, capture_logs -from authentik.lib.utils.file import ( - FilePathSerializer, - FileUploadSerializer, - set_file, - set_file_url, -) from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.engine import PolicyEngine from authentik.policies.types import CACHE_PREFIX, PolicyResult -from authentik.rbac.decorators import permission_required from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() @@ -58,7 +50,7 @@ class ApplicationSerializer(ModelSerializer): source="backchannel_providers", required=False, read_only=True, many=True ) - meta_icon = ReadOnlyField(source="get_meta_icon") + meta_icon_url = ReadOnlyField(source="get_meta_icon") def get_launch_url(self, app: Application) -> str | None: """Allow formatting of launch URL""" @@ -95,13 +87,13 @@ class ApplicationSerializer(ModelSerializer): "open_in_new_tab", "meta_launch_url", "meta_icon", + "meta_icon_url", "meta_description", "meta_publisher", "policy_engine_mode", "group", ] extra_kwargs = { - "meta_icon": {"read_only": True}, "backchannel_providers": {"required": False}, } @@ -286,44 +278,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): serializer = self.get_serializer(allowed_applications, many=True) return self.get_paginated_response(serializer.data) - - @permission_required("authentik_core.change_application") - @extend_schema( - request={ - "multipart/form-data": FileUploadSerializer, - }, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - parser_classes=(MultiPartParser,), - ) - def set_icon(self, request: Request, slug: str): - """Set application icon""" - app: Application = self.get_object() - return set_file(request, app, "meta_icon") - - @permission_required("authentik_core.change_application") - @extend_schema( - request=FilePathSerializer, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - ) - def set_icon_url(self, request: Request, slug: str): - """Set application icon (as URL)""" - app: Application = self.get_object() - return set_file_url(request, app, "meta_icon") diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index 45abb1a451..778ae4d9bc 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -2,31 +2,22 @@ from collections.abc import Iterable -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import extend_schema from rest_framework import mixins from rest_framework.decorators import action from rest_framework.exceptions import ValidationError -from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField -from rest_framework.parsers import MultiPartParser +from rest_framework.fields import ReadOnlyField, SerializerMethodField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet from structlog.stdlib import get_logger -from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, ModelSerializer from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection from authentik.core.types import UserSettingSerializer -from authentik.lib.utils.file import ( - FilePathSerializer, - FileUploadSerializer, - set_file, - set_file_url, -) from authentik.policies.engine import PolicyEngine -from authentik.rbac.decorators import permission_required LOGGER = get_logger() @@ -36,7 +27,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): managed = ReadOnlyField() component = SerializerMethodField() - icon = ReadOnlyField(source="icon_url") + icon_url = ReadOnlyField() def get_component(self, obj: Source) -> str: """Get object component so that we know how to edit the object""" @@ -44,11 +35,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): return "" return obj.component - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - if SERIALIZER_CONTEXT_BLUEPRINT in self.context: - self.fields["icon"] = CharField(required=False) - class Meta: model = Source fields = [ @@ -70,6 +56,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "managed", "user_path_template", "icon", + "icon_url", ] @@ -92,47 +79,6 @@ class SourceViewSet( def get_queryset(self): # pragma: no cover return Source.objects.select_subclasses() - @permission_required("authentik_core.change_source") - @extend_schema( - request={ - "multipart/form-data": FileUploadSerializer, - }, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - parser_classes=(MultiPartParser,), - ) - def set_icon(self, request: Request, slug: str): - """Set source icon""" - source: Source = self.get_object() - return set_file(request, source, "icon") - - @permission_required("authentik_core.change_source") - @extend_schema( - request=FilePathSerializer, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - ) - def set_icon_url(self, request: Request, slug: str): - """Set source icon (as URL)""" - source: Source = self.get_object() - return set_file_url(request, source, "icon") - @extend_schema(responses={200: UserSettingSerializer(many=True)}) @action(detail=False, pagination_class=None, filter_backends=[]) def user_settings(self, request: Request) -> Response: diff --git a/authentik/core/api/used_by.py b/authentik/core/api/used_by.py index 01b0c41cbf..cf08fe40ec 100644 --- a/authentik/core/api/used_by.py +++ b/authentik/core/api/used_by.py @@ -24,6 +24,7 @@ class DeleteAction(Enum): CASCADE_MANY = "cascade_many" SET_NULL = "set_null" SET_DEFAULT = "set_default" + LEFT_DANGLING = "left_dangling" class UsedBySerializer(PassiveSerializer): diff --git a/authentik/core/migrations/0054_alter_application_meta_icon_alter_source_icon.py b/authentik/core/migrations/0054_alter_application_meta_icon_alter_source_icon.py new file mode 100644 index 0000000000..cd47f3bc35 --- /dev/null +++ b/authentik/core/migrations/0054_alter_application_meta_icon_alter_source_icon.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.8 on 2025-11-27 16:22 + +import authentik.admin.files.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0053_alter_application_slug_alter_source_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="application", + name="meta_icon", + field=authentik.admin.files.fields.FileField(blank=True, default=""), + ), + migrations.AlterField( + model_name="source", + name="icon", + field=authentik.admin.files.fields.FileField(blank=True, default=""), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 84898bda21..b0f922d720 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -26,11 +26,13 @@ from model_utils.managers import InheritanceManager from rest_framework.serializers import Serializer from structlog.stdlib import get_logger +from authentik.admin.files.fields import FileField +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileUsage from authentik.blueprints.models import ManagedModel from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.lib.avatars import get_avatar -from authentik.lib.config import CONFIG from authentik.lib.expression.exceptions import ControlFlowException from authentik.lib.generators import generate_id from authentik.lib.merge import MERGE_LIST_UNIQUE @@ -554,13 +556,7 @@ class Application(SerializerModel, PolicyBindingModel): default=False, help_text=_("Open launch URL in a new browser tab or window.") ) - # For template applications, this can be set to /static/authentik/applications/* - meta_icon = models.FileField( - upload_to="application-icons/", - default=None, - null=True, - max_length=500, - ) + meta_icon = FileField(default="", blank=True) meta_description = models.TextField(default="", blank=True) meta_publisher = models.TextField(default="", blank=True) @@ -577,17 +573,11 @@ class Application(SerializerModel, PolicyBindingModel): @property def get_meta_icon(self) -> str | None: - """Get the URL to the App Icon image. If the name is /static or starts with http - it is returned as-is""" + """Get the URL to the App Icon image""" if not self.meta_icon: return None - if self.meta_icon.name.startswith("http"): - return self.meta_icon.name - if self.meta_icon.name.startswith("fa://"): - return self.meta_icon.name - if self.meta_icon.name.startswith("/"): - return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name - return self.meta_icon.url + + return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon) def get_launch_url(self, user: Optional["User"] = None) -> str | None: """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" @@ -747,12 +737,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): group_property_mappings = models.ManyToManyField( "PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set" ) - icon = models.FileField( - upload_to="source-icons/", - default=None, - null=True, - max_length=500, - ) + + icon = FileField(blank=True, default="") authentication_flow = models.ForeignKey( "authentik_flows.Flow", @@ -793,17 +779,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): @property def icon_url(self) -> str | None: - """Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is""" + """Get the URL to the source icon""" if not self.icon: return None - if self.icon.name.startswith("http"): - return self.icon.name - if self.icon.name.startswith("fa://"): - return self.icon.name - if self.icon.name.startswith("/"): - return CONFIG.get("web.path", "/")[:-1] + self.icon.name - return self.icon.url + + return get_file_manager(FileUsage.MEDIA).file_url(self.icon) def get_user_path(self) -> str: """Get user path, fallback to default for formatting errors""" diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py index 61d90081da..c0bc341e91 100644 --- a/authentik/core/tests/test_applications_api.py +++ b/authentik/core/tests/test_applications_api.py @@ -2,8 +2,6 @@ from json import loads -from django.core.files.base import ContentFile -from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart from django.urls import reverse from rest_framework.test import APITestCase @@ -57,91 +55,6 @@ class TestApplicationsAPI(APITestCase): f"https://{self.user.username}-test.test.goauthentik.io/{self.user.username}", ) - def test_set_icon(self): - """Test set_icon""" - file = ContentFile(b"text", "name") - self.client.force_login(self.user) - response = self.client.post( - reverse( - "authentik_api:application-set-icon", - kwargs={"slug": self.allowed.slug}, - ), - data=encode_multipart(data={"file": file}, boundary=BOUNDARY), - content_type=MULTIPART_CONTENT, - ) - self.assertEqual(response.status_code, 200) - - app_raw = self.client.get( - reverse( - "authentik_api:application-detail", - kwargs={"slug": self.allowed.slug}, - ), - ) - app = loads(app_raw.content) - self.allowed.refresh_from_db() - self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"]) - self.assertEqual(self.allowed.meta_icon.read(), b"text") - - def test_set_icon_relative(self): - """Test set_icon (relative path)""" - self.client.force_login(self.user) - response = self.client.post( - reverse( - "authentik_api:application-set-icon-url", - kwargs={"slug": self.allowed.slug}, - ), - data={"url": "relative/path"}, - ) - self.assertEqual(response.status_code, 200) - - self.allowed.refresh_from_db() - self.assertEqual(self.allowed.get_meta_icon, "/media/public/relative/path") - - def test_set_icon_absolute(self): - """Test set_icon (absolute path)""" - self.client.force_login(self.user) - response = self.client.post( - reverse( - "authentik_api:application-set-icon-url", - kwargs={"slug": self.allowed.slug}, - ), - data={"url": "/relative/path"}, - ) - self.assertEqual(response.status_code, 200) - - self.allowed.refresh_from_db() - self.assertEqual(self.allowed.get_meta_icon, "/relative/path") - - def test_set_icon_url(self): - """Test set_icon (url)""" - self.client.force_login(self.user) - response = self.client.post( - reverse( - "authentik_api:application-set-icon-url", - kwargs={"slug": self.allowed.slug}, - ), - data={"url": "https://authentik.company/img.png"}, - ) - self.assertEqual(response.status_code, 200) - - self.allowed.refresh_from_db() - self.assertEqual(self.allowed.get_meta_icon, "https://authentik.company/img.png") - - def test_set_icon_fa(self): - """Test set_icon (url)""" - self.client.force_login(self.user) - response = self.client.post( - reverse( - "authentik_api:application-set-icon-url", - kwargs={"slug": self.allowed.slug}, - ), - data={"url": "fa://fa-check-circle"}, - ) - self.assertEqual(response.status_code, 200) - - self.allowed.refresh_from_db() - self.assertEqual(self.allowed.get_meta_icon, "fa://fa-check-circle") - def test_check_access(self): """Test check_access operation""" self.client.force_login(self.user) @@ -210,7 +123,8 @@ class TestApplicationsAPI(APITestCase): "launch_url": f"https://goauthentik.io/{self.user.username}", "meta_launch_url": "https://goauthentik.io/%(username)s", "open_in_new_tab": True, - "meta_icon": None, + "meta_icon": "", + "meta_icon_url": None, "meta_description": "", "meta_publisher": "", "policy_engine_mode": "any", @@ -264,7 +178,8 @@ class TestApplicationsAPI(APITestCase): "launch_url": f"https://goauthentik.io/{self.user.username}", "meta_launch_url": "https://goauthentik.io/%(username)s", "open_in_new_tab": True, - "meta_icon": None, + "meta_icon": "", + "meta_icon_url": None, "meta_description": "", "meta_publisher": "", "policy_engine_mode": "any", @@ -272,7 +187,8 @@ class TestApplicationsAPI(APITestCase): { "launch_url": None, "meta_description": "", - "meta_icon": None, + "meta_icon": "", + "meta_icon_url": None, "meta_launch_url": "", "open_in_new_tab": False, "meta_publisher": "", diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 70bee5674c..1074b4982a 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.decorators import action -from rest_framework.fields import BooleanField, CharField, ReadOnlyField, SerializerMethodField +from rest_framework.fields import BooleanField, FileField, ReadOnlyField, SerializerMethodField from rest_framework.parsers import MultiPartParser from rest_framework.request import Request from rest_framework.response import Response @@ -15,7 +15,7 @@ from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger from authentik.blueprints.v1.exporter import FlowExporter -from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, Importer +from authentik.blueprints.v1.importer import Importer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import ( CacheSerializer, @@ -29,12 +29,6 @@ from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import Flow from authentik.flows.planner import CACHE_PREFIX, PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN -from authentik.lib.utils.file import ( - FilePathSerializer, - FileUploadSerializer, - set_file, - set_file_url, -) from authentik.lib.views import bad_request_message from authentik.rbac.decorators import permission_required from authentik.rbac.filters import ObjectFilter @@ -42,10 +36,17 @@ from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() +class FlowUploadSerializer(PassiveSerializer): + """Serializer to upload file""" + + file = FileField(required=False) + clear = BooleanField(default=False) + + class FlowSerializer(ModelSerializer): """Flow Serializer""" - background = ReadOnlyField(source="background_url") + background_url = ReadOnlyField() cache_count = SerializerMethodField() export_url = SerializerMethodField() @@ -58,11 +59,6 @@ class FlowSerializer(ModelSerializer): """Get export URL for flow""" return reverse("authentik_api:flow-export", kwargs={"slug": flow.slug}) - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - if SERIALIZER_CONTEXT_BLUEPRINT in self.context: - self.fields["background"] = CharField(required=False) - class Meta: model = Flow fields = [ @@ -73,6 +69,7 @@ class FlowSerializer(ModelSerializer): "title", "designation", "background", + "background_url", "stages", "policies", "cache_count", @@ -83,9 +80,6 @@ class FlowSerializer(ModelSerializer): "denied_action", "authentication", ] - extra_kwargs = { - "background": {"read_only": True}, - } class FlowSetSerializer(FlowSerializer): @@ -100,7 +94,7 @@ class FlowSetSerializer(FlowSerializer): "slug", "title", "designation", - "background", + "background_url", "policy_engine_mode", "compatibility_mode", "export_url", @@ -167,7 +161,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): ], ) @extend_schema( - request={"multipart/form-data": FileUploadSerializer}, + request={"multipart/form-data": FlowUploadSerializer}, responses={ 204: FlowImportResultSerializer, 400: FlowImportResultSerializer, @@ -235,47 +229,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): output = diagram.build() return Response({"diagram": output}) - @permission_required("authentik_flows.change_flow") - @extend_schema( - request={ - "multipart/form-data": FileUploadSerializer, - }, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - parser_classes=(MultiPartParser,), - ) - def set_background(self, request: Request, slug: str): - """Set Flow background""" - flow: Flow = self.get_object() - return set_file(request, flow, "background") - - @permission_required("authentik_core.change_application") - @extend_schema( - request=FilePathSerializer, - responses={ - 200: OpenApiResponse(description="Success"), - 400: OpenApiResponse(description="Bad request"), - }, - ) - @action( - detail=True, - pagination_class=None, - filter_backends=[], - methods=["POST"], - ) - def set_background_url(self, request: Request, slug: str): - """Set Flow background (as URL)""" - flow: Flow = self.get_object() - return set_file_url(request, flow, "background") - @extend_schema( responses={ 200: LinkSerializer(many=False), diff --git a/authentik/flows/migrations/0030_alter_flow_background.py b/authentik/flows/migrations/0030_alter_flow_background.py new file mode 100644 index 0000000000..a5bde40b2b --- /dev/null +++ b/authentik/flows/migrations/0030_alter_flow_background.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.8 on 2025-11-27 16:22 + +import authentik.admin.files.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0029_alter_flow_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="flow", + name="background", + field=authentik.admin.files.fields.FileField( + blank=True, default="", help_text="Background shown during execution" + ), + ), + ] diff --git a/authentik/flows/models.py b/authentik/flows/models.py index f96ec78dc2..d3b019ec9b 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -13,6 +13,9 @@ from model_utils.managers import InheritanceManager from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger +from authentik.admin.files.fields import FileField +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileUsage from authentik.core.models import Token from authentik.core.types import UserSettingSerializer from authentik.flows.challenge import FlowLayout @@ -156,12 +159,10 @@ class Flow(SerializerModel, PolicyBindingModel): ), ) - background = models.FileField( - upload_to="flow-backgrounds/", - default=None, - null=True, + background = FileField( + blank=True, + default="", help_text=_("Background shown during execution"), - max_length=500, ) compatibility_mode = models.BooleanField( @@ -185,19 +186,15 @@ class Flow(SerializerModel, PolicyBindingModel): ) def background_url(self, request: HttpRequest | None = None) -> str: - """Get the URL to the background image. If the name is /static or starts with http - it is returned as-is""" + """Get the URL to the background image""" if not self.background: if request: return request.brand.branding_default_flow_background_url() return ( CONFIG.get("web.path", "/")[:-1] + "/static/dist/assets/images/flow_background.jpg" ) - if self.background.name.startswith("http"): - return self.background.name - if self.background.name.startswith("/"): - return CONFIG.get("web.path", "/")[:-1] + self.background.name - return self.background.url + + return get_file_manager(FileUsage.MEDIA).file_url(self.background, request) stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) diff --git a/authentik/flows/tests/test_api.py b/authentik/flows/tests/test_api.py index 4b14817b47..cb164b2621 100644 --- a/authentik/flows/tests/test_api.py +++ b/authentik/flows/tests/test_api.py @@ -87,7 +87,7 @@ class TestFlowsAPI(APITestCase): flow = create_test_flow() response = self.client.get(reverse("authentik_api:flow-detail", kwargs={"slug": flow.slug})) body = loads(response.content.decode()) - self.assertEqual(body["background"], "/static/dist/assets/images/flow_background.jpg") + self.assertEqual(body["background_url"], "/static/dist/assets/images/flow_background.jpg") flow.background = "https://goauthentik.io/img/icon.png" flow.save() diff --git a/authentik/lib/config.py b/authentik/lib/config.py index 69ce7b1e32..170a3f7f3a 100644 --- a/authentik/lib/config.py +++ b/authentik/lib/config.py @@ -20,7 +20,7 @@ from urllib.parse import urlparse import yaml from django.conf import ImproperlyConfigured -from authentik.lib.utils.dict import get_path_from_dict, set_path_in_dict +from authentik.lib.utils.dict import delete_path_in_dict, get_path_from_dict, set_path_in_dict SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( "/etc/authentik/config.d/*.yml", recursive=True @@ -237,12 +237,15 @@ class ConfigLoader: @contextmanager def patch(self, path: str, value: Any): """Context manager for unittests to patch a value""" - original_value = self.get(path) + original_value = self.get(path, UNSET) self.set(path, value) try: yield finally: - self.set(path, original_value) + if original_value is not UNSET: + self.set(path, original_value) + else: + self.delete(path) @property def raw(self) -> dict: @@ -314,6 +317,9 @@ class ConfigLoader: value = Attr(value) set_path_in_dict(self.raw, path, value, sep=sep) + def delete(self, path: str, sep="."): + delete_path_in_dict(self.raw, path, sep=sep) + CONFIG = ConfigLoader() diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 0c38adda44..07e8562ecb 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -150,18 +150,28 @@ worker: scheduler_interval: "seconds=60" storage: - media: - backend: file # or s3 - file: - path: ./media - s3: - # How to talk to s3 - # region: "us-east-1" - # use_ssl: True - # endpoint: "https://s3.us-east-1.amazonaws.com" - # access_key: "" - # secret_key: "" - # bucket_name: "authentik-media" - # How to render file URLs - # custom_domain: null - secure_urls: True + backend: file # or s3 + file: + path: ./data + url_expiry: "minutes=15" + s3: + # How to talk to s3 + # region: "us-east-1" + # use_ssl: True + # endpoint: "https://s3.us-east-1.amazonaws.com" + # access_key: "" + # secret_key: "" + # bucket_name: "authentik-data" + # How to render file URLs + # custom_domain: null + secure_urls: True + url_expiry: "minutes=15" + # Usage based settings. Same schema as global settings, overrides global settings + # media: + # backend: file # or s3 + # file: {} + # s3: {} + # reports: + # backend: file # or s3 + # file: {} + # s3: {} diff --git a/authentik/lib/utils/dict.py b/authentik/lib/utils/dict.py index 403191c9cd..4662b00d33 100644 --- a/authentik/lib/utils/dict.py +++ b/authentik/lib/utils/dict.py @@ -23,3 +23,17 @@ def set_path_in_dict(root: dict, path: str, value: Any, sep="."): root[comp] = {} root = root.get(comp, {}) root[path_parts[-1]] = value + + +def delete_path_in_dict(root: dict, path: str, sep="."): + """Recursively walk through `root`, checking each part of `path` separated by `sep` + and delete the last value""" + # Walk each component of the path + path_parts = path.split(sep) + for comp in path_parts[:-1]: + if comp not in root: + return + root = root.get(comp, {}) + last_path_part = path_parts[-1] + if last_path_part in root: + del root[last_path_part] diff --git a/authentik/lib/utils/file.py b/authentik/lib/utils/file.py deleted file mode 100644 index efa26d053f..0000000000 --- a/authentik/lib/utils/file.py +++ /dev/null @@ -1,56 +0,0 @@ -"""file utils""" - -from django.db.models import Model -from django.http import HttpResponseBadRequest -from rest_framework.fields import BooleanField, CharField, FileField -from rest_framework.request import Request -from rest_framework.response import Response -from structlog import get_logger - -from authentik.core.api.utils import PassiveSerializer - -LOGGER = get_logger() - - -class FileUploadSerializer(PassiveSerializer): - """Serializer to upload file""" - - file = FileField(required=False) - clear = BooleanField(default=False) - - -class FilePathSerializer(PassiveSerializer): - """Serializer to upload file""" - - url = CharField() - - -def set_file(request: Request, obj: Model, field_name: str): - """Upload file""" - field = getattr(obj, field_name) - file = request.FILES.get("file", None) - clear = request.data.get("clear", "false").lower() == "true" - if clear: - # .delete() saves the model by default - field.delete() - return Response({}) - if file: - setattr(obj, field_name, file) - try: - obj.save() - except PermissionError as exc: - LOGGER.warning("Failed to save file", exc=exc) - return HttpResponseBadRequest() - return Response({}) - return HttpResponseBadRequest() - - -def set_file_url(request: Request, obj: Model, field_name: str): - """Set file field to URL""" - field = getattr(obj, field_name) - url = request.data.get("url", None) - if url is None: - return HttpResponseBadRequest() - field.name = url - obj.save() - return Response({}) diff --git a/authentik/rbac/migrations/0007_alter_systempermission_options.py b/authentik/rbac/migrations/0007_alter_systempermission_options.py new file mode 100644 index 0000000000..376bcab07c --- /dev/null +++ b/authentik/rbac/migrations/0007_alter_systempermission_options.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.8 on 2025-11-27 14:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_rbac", "0006_alter_role_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="systempermission", + options={ + "default_permissions": (), + "managed": False, + "permissions": [ + ("view_system_info", "Can view system info"), + ("access_admin_interface", "Can access admin interface"), + ("view_system_settings", "Can view system settings"), + ("edit_system_settings", "Can edit system settings"), + ("view_media_files", "Can view media files"), + ("manage_media_files", "Can manage media files"), + ], + "verbose_name": "System permission", + "verbose_name_plural": "System permissions", + }, + ), + ] diff --git a/authentik/rbac/models.py b/authentik/rbac/models.py index 4ddffe226e..974c3719cb 100644 --- a/authentik/rbac/models.py +++ b/authentik/rbac/models.py @@ -119,6 +119,8 @@ class SystemPermission(models.Model): ("access_admin_interface", _("Can access admin interface")), ("view_system_settings", _("Can view system settings")), ("edit_system_settings", _("Can edit system settings")), + ("view_media_files", _("Can view media files")), + ("manage_media_files", _("Can manage media files")), ] def __str__(self) -> str: diff --git a/authentik/root/settings.py b/authentik/root/settings.py index deeaf59747..035432bfde 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -86,6 +86,7 @@ TENANT_APPS = [ "authentik.endpoints.connectors.agent", "authentik.enterprise", "authentik.events", + "authentik.admin.files", "authentik.flows", "authentik.outposts", "authentik.policies.dummy", @@ -479,46 +480,6 @@ STORAGES = { # 8192 should cover most cases. http_response.MAX_URL_LENGTH = http_response.MAX_URL_LENGTH * 4 - -# Media files -if CONFIG.get("storage.media.backend", "file") == "s3": - STORAGES["default"] = { - "BACKEND": "authentik.root.storages.S3Storage", - "OPTIONS": { - # How to talk to S3 - "session_profile": CONFIG.get("storage.media.s3.session_profile", None), - "access_key": CONFIG.get("storage.media.s3.access_key", None), - "secret_key": CONFIG.get("storage.media.s3.secret_key", None), - "security_token": CONFIG.get("storage.media.s3.security_token", None), - "region_name": CONFIG.get("storage.media.s3.region", None), - "use_ssl": CONFIG.get_bool("storage.media.s3.use_ssl", True), - "endpoint_url": CONFIG.get("storage.media.s3.endpoint", None), - "bucket_name": CONFIG.get("storage.media.s3.bucket_name"), - "default_acl": "private", - "querystring_auth": True, - "signature_version": "s3v4", - "file_overwrite": False, - "location": "media", - "url_protocol": ( - "https:" if CONFIG.get("storage.media.s3.secure_urls", True) else "http:" - ), - "custom_domain": CONFIG.get("storage.media.s3.custom_domain", None), - }, - } -# Fallback on file storage backend -else: - STORAGES["default"] = { - "BACKEND": "authentik.root.storages.FileStorage", - "OPTIONS": { - "location": Path(CONFIG.get("storage.media.file.path")), - "base_url": CONFIG.get("web.path", "/") + "media/", - }, - } - # Compatibility for apps not supporting top-level STORAGES - # such as django-tenants - MEDIA_ROOT = STORAGES["default"]["OPTIONS"]["location"] - MEDIA_URL = STORAGES["default"]["OPTIONS"]["base_url"] - structlog_configure() LOGGING = get_logger_config() diff --git a/authentik/root/storages.py b/authentik/root/storages.py deleted file mode 100644 index e76efb3374..0000000000 --- a/authentik/root/storages.py +++ /dev/null @@ -1,144 +0,0 @@ -"""authentik storage backends""" - -import os -from urllib.parse import parse_qsl, urlsplit - -from django.conf import settings -from django.core.exceptions import SuspiciousOperation -from django.core.files.storage import FileSystemStorage -from django.db import connection -from storages.backends.s3 import S3Storage as BaseS3Storage -from storages.utils import clean_name, safe_join - -from authentik.lib.config import CONFIG - - -class FileStorage(FileSystemStorage): - """File storage backend""" - - @property - def base_location(self): - return os.path.join( - self._value_or_setting(self._location, settings.MEDIA_ROOT), connection.schema_name - ) - - @property - def location(self): - return os.path.abspath(self.base_location) - - @property - def base_url(self): - if self._base_url is not None and not self._base_url.endswith("/"): - self._base_url += "/" - return f"{self._base_url}/{connection.schema_name}/" - - -class S3Storage(BaseS3Storage): - """S3 storage backend""" - - @property - def session_profile(self) -> str | None: - """Get session profile""" - return CONFIG.refresh("storage.media.s3.session_profile", None) - - @session_profile.setter - def session_profile(self, value: str): - pass - - @property - def access_key(self) -> str | None: - """Get access key""" - return CONFIG.refresh("storage.media.s3.access_key", None) - - @access_key.setter - def access_key(self, value: str): - pass - - @property - def secret_key(self) -> str | None: - """Get secret key""" - return CONFIG.refresh("storage.media.s3.secret_key", None) - - @secret_key.setter - def secret_key(self, value: str): - pass - - @property - def security_token(self) -> str | None: - """Get security token""" - return CONFIG.refresh("storage.media.s3.security_token", None) - - @security_token.setter - def security_token(self, value: str): - pass - - def _normalize_name(self, name): - try: - - return safe_join(self.location, connection.schema_name, name) - except ValueError: - raise SuspiciousOperation(f"Attempted access to '{name}' denied.") from None - - # This is a fix for https://github.com/jschneier/django-storages/pull/839 - def url(self, name, parameters=None, expire=None, http_method=None): - # Preserve the trailing slash after normalizing the path. - name = self._normalize_name(clean_name(name)) - params = parameters.copy() if parameters else {} - if expire is None: - expire = self.querystring_expire - - params["Bucket"] = self.bucket.name - params["Key"] = name - url = self.bucket.meta.client.generate_presigned_url( - "get_object", - Params=params, - ExpiresIn=expire, - HttpMethod=http_method, - ) - - if self.custom_domain: - # Key parameter can't be empty. Use "/" and remove it later. - params["Key"] = "/" - root_url_signed = self.bucket.meta.client.generate_presigned_url( - "get_object", Params=params, ExpiresIn=expire - ) - # Remove signing parameter and previously added key "/". - root_url = self._strip_signing_parameters(root_url_signed)[:-1] - # Replace bucket domain with custom domain. - custom_url = f"{self.url_protocol}//{self.custom_domain}/" - url = url.replace(root_url, custom_url) - - if self.querystring_auth: - return url - return self._strip_signing_parameters(url) - - def _strip_signing_parameters(self, url): - # Boto3 does not currently support generating URLs that are unsigned. Instead - # we take the signed URLs and strip any querystring params related to signing - # and expiration. - # Note that this may end up with URLs that are still invalid, especially if - # params are passed in that only work with signed URLs, e.g. response header - # params. - # The code attempts to strip all query parameters that match names of known - # parameters from v2 and v4 signatures, regardless of the actual signature - # version used. - split_url = urlsplit(url) - qs = parse_qsl(split_url.query, keep_blank_values=True) - blacklist = { - "x-amz-algorithm", - "x-amz-credential", - "x-amz-date", - "x-amz-expires", - "x-amz-signedheaders", - "x-amz-signature", - "x-amz-security-token", - "awsaccesskeyid", - "expires", - "signature", - } - filtered_qs = ((key, val) for key, val in qs if key.lower() not in blacklist) - # Note: Parameters that did not have a value in the original query string will - # have an '=' sign appended to it, e.g ?foo&bar becomes ?foo=&bar= - joined_qs = ("=".join(keyval) for keyval in filtered_qs) - split_url = split_url._replace(query="&".join(joined_qs)) - return split_url.geturl() diff --git a/blueprints/schema.json b/blueprints/schema.json index 96a8d808d8..d6dc9f95af 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -4870,6 +4870,10 @@ "type": "string", "title": "Meta launch url" }, + "meta_icon": { + "type": "string", + "title": "Meta icon" + }, "meta_description": { "type": "string", "title": "Meta description" @@ -5593,8 +5597,10 @@ "authentik_rbac.delete_initialpermissions", "authentik_rbac.delete_role", "authentik_rbac.edit_system_settings", + "authentik_rbac.manage_media_files", "authentik_rbac.unassign_role_permissions", "authentik_rbac.view_initialpermissions", + "authentik_rbac.view_media_files", "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", @@ -7719,8 +7725,8 @@ }, "background": { "type": "string", - "minLength": 1, - "title": "Background" + "title": "Background", + "description": "Background shown during execution" }, "policy_engine_mode": { "type": "string", @@ -8201,6 +8207,7 @@ "authentik.endpoints.connectors.agent", "authentik.enterprise", "authentik.events", + "authentik.admin.files", "authentik.flows", "authentik.outposts", "authentik.policies.dummy", @@ -10891,8 +10898,10 @@ "authentik_rbac.delete_initialpermissions", "authentik_rbac.delete_role", "authentik_rbac.edit_system_settings", + "authentik_rbac.manage_media_files", "authentik_rbac.unassign_role_permissions", "authentik_rbac.view_initialpermissions", + "authentik_rbac.view_media_files", "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", @@ -11221,11 +11230,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -11335,7 +11339,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "group_matching_mode": { @@ -11514,11 +11517,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -11565,11 +11563,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -11679,7 +11672,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "server_uri": { @@ -11888,11 +11880,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -11939,11 +11926,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -12053,7 +12035,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "group_matching_mode": { @@ -12275,11 +12256,6 @@ "type": "string", "format": "date-time", "title": "Expires" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -12326,11 +12302,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -12440,7 +12411,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "group_matching_mode": { @@ -12580,11 +12550,6 @@ "type": "string", "minLength": 1, "title": "Plex token" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -12631,11 +12596,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -12745,7 +12705,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "group_matching_mode": { @@ -12962,11 +12921,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -13036,11 +12990,6 @@ "type": "string", "minLength": 1, "title": "User path template" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -13138,11 +13087,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] @@ -13252,7 +13196,6 @@ }, "icon": { "type": "string", - "minLength": 1, "title": "Icon" }, "bot_username": { @@ -13373,11 +13316,6 @@ "type": "string", "minLength": 1, "title": "Identifier" - }, - "icon": { - "type": "string", - "minLength": 1, - "title": "Icon" } }, "required": [] diff --git a/docker-compose.yml b/docker-compose.yml index 71d197788c..749cb8e8de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: - ${COMPOSE_PORT_HTTPS:-9443}:9443 restart: unless-stopped volumes: - - ./media:/media + - ./media:/data/media - ./custom-templates:/templates worker: command: worker @@ -57,7 +57,7 @@ services: user: root volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./media:/media + - ./media:/data/media - ./certs:/certs - ./custom-templates:/templates volumes: diff --git a/internal/config/struct.go b/internal/config/struct.go index 3b075bb0d7..d338492729 100644 --- a/internal/config/struct.go +++ b/internal/config/struct.go @@ -60,18 +60,34 @@ type ListenConfig struct { } type StorageConfig struct { - Media StorageMediaConfig `yaml:"media"` -} - -type StorageMediaConfig struct { - Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__MEDIA__BACKEND"` - File StorageFileConfig `yaml:"file"` + Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__BACKEND"` + File StorageFileConfig `yaml:"file"` + Media StorageMediaConfig `yaml:"media"` + Reports StorageReportsConfig `yaml:"reports"` } type StorageFileConfig struct { + Path string `yaml:"path" env:"AUTHENTIK_STORAGE__FILE__PATH"` +} + +type StorageMediaConfig struct { + Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__MEDIA__BACKEND"` + File StorageMediaFileConfig `yaml:"file"` +} + +type StorageMediaFileConfig struct { Path string `yaml:"path" env:"AUTHENTIK_STORAGE__MEDIA__FILE__PATH"` } +type StorageReportsConfig struct { + Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__REPORTS__BACKEND"` + File StorageReportsFileConfig `yaml:"file"` +} + +type StorageReportsFileConfig struct { + Path string `yaml:"path" env:"AUTHENTIK_STORAGE__REPORTS__FILE__PATH"` +} + type ErrorReportingConfig struct { Enabled bool `yaml:"enabled" env:"ENABLED, overwrite"` SentryDSN string `yaml:"sentry_dsn" env:"SENTRY_DSN, overwrite"` diff --git a/internal/web/static.go b/internal/web/static.go index 37cabac0b0..1050bfa2c8 100644 --- a/internal/web/static.go +++ b/internal/web/static.go @@ -1,10 +1,14 @@ package web import ( + "crypto/sha256" + "encoding/hex" "fmt" "net/http" + "time" "github.com/go-http-utils/etag" + "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "goauthentik.io/internal/config" @@ -13,6 +17,47 @@ import ( staticWeb "goauthentik.io/web" ) +type StorageClaims struct { + jwt.RegisteredClaims + Path string `json:"path,omitempty"` +} + +func storageTokenIsValid(usage string, r *http.Request) bool { + tokenString := r.URL.Query().Get("token") + if tokenString == "" { + return false + } + claims := &StorageClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + key := []byte(fmt.Sprintf("%s:%s", config.Get().SecretKey, usage)) + hash := sha256.Sum256(key) + hexDigest := hex.EncodeToString(hash[:]) + return []byte(hexDigest), nil + }) + if err != nil || !token.Valid { + return false + } + + now := time.Now() + + if claims.ExpiresAt != nil && claims.ExpiresAt.Before(now) { + return false + } + if claims.NotBefore != nil && claims.NotBefore.After(now) { + return false + } + + if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) { + return false + } + + return true +} + func (ws *WebServer) configureStatic() { // Setup routers staticRouter := ws.loggingRouter.NewRoute().Subrouter() @@ -61,15 +106,63 @@ func (ws *WebServer) configureStatic() { ).ServeHTTP(rw, r) }) - // Media files, if backend is file - if config.Get().Storage.Media.Backend == "file" { - fsMedia := http.FileServer(http.Dir(config.Get().Storage.Media.File.Path)) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/media/").Handler(pathStripper( + // Files, if backend is file + defaultBackend := config.Get().Storage.Backend + if defaultBackend == "" { + defaultBackend = "file" + } + mediaBackend := config.Get().Storage.Media.Backend + if mediaBackend == "" { + mediaBackend = defaultBackend + } + reportsBackend := config.Get().Storage.Reports.Backend + if reportsBackend == "" { + reportsBackend = defaultBackend + } + + defaultStoragePath := config.Get().Storage.File.Path + if defaultStoragePath == "" { + defaultStoragePath = "./data" + } + + if mediaBackend == "file" { + mediaPath := config.Get().Storage.Media.File.Path + if mediaPath == "" { + mediaPath = defaultStoragePath + } + mediaPath = mediaPath + "/media" + fsMedia := http.FileServer(http.Dir(mediaPath)) + staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/media/").Handler(pathStripper( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !storageTokenIsValid("media", r) { + http.Error(w, "404 page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") fsMedia.ServeHTTP(w, r) }), - "media/", + "files/media/", + config.Get().Web.Path, + )) + } + + if reportsBackend == "file" { + reportsPath := config.Get().Storage.Reports.File.Path + if reportsPath == "" { + reportsPath = defaultStoragePath + } + reportsPath = reportsPath + "/reports" + fsReports := http.FileServer(http.Dir(reportsPath)) + staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/reports/").Handler(pathStripper( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !storageTokenIsValid("reports", r) { + http.Error(w, "404 page not found", http.StatusNotFound) + return + } + fsReports.ServeHTTP(w, r) + }), + "files/reports/", config.Get().Web.Path, )) } diff --git a/schema.yml b/schema.yml index c204451fc0..25519e63af 100644 --- a/schema.yml +++ b/schema.yml @@ -30,6 +30,108 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /admin/file/: + get: + operationId: admin_file_list + description: List files from storage backend. + parameters: + - in: query + name: manageable_only + schema: + type: boolean + default: false + - $ref: '#/components/parameters/QuerySearch' + - in: query + name: usage + schema: + enum: + - media + type: string + default: media + minLength: 1 + tags: + - admin + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FileList' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' + post: + operationId: admin_file_create + description: Upload file to storage backend. + tags: + - admin + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FileUploadRequest' + required: true + security: + - authentik: [] + responses: + '200': + description: No response body + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' + delete: + operationId: admin_file_destroy + description: Delete file from storage backend. + parameters: + - $ref: '#/components/parameters/QueryName' + - in: query + name: usage + schema: + enum: + - media + type: string + default: media + minLength: 1 + tags: + - admin + security: + - authentik: [] + responses: + '200': + description: No response body + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' + /admin/file/used_by/: + get: + operationId: admin_file_used_by_list + parameters: + - $ref: '#/components/parameters/QueryName' + tags: + - admin + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /admin/models/: get: operationId: admin_models_list @@ -2847,61 +2949,6 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' - /core/applications/{slug}/set_icon/: - post: - operationId: core_applications_set_icon_create - description: Set application icon - parameters: - - in: path - name: slug - schema: - type: string - description: Internal application name, used in URLs. - required: true - tags: - - core - requestBody: - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/FileUploadRequest' - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' - /core/applications/{slug}/set_icon_url/: - post: - operationId: core_applications_set_icon_url_create - description: Set application icon (as URL) - parameters: - - in: path - name: slug - schema: - type: string - description: Internal application name, used in URLs. - required: true - tags: - - core - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FilePathRequest' - required: true - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' /core/applications/{slug}/used_by/: get: operationId: core_applications_used_by_list @@ -8045,61 +8092,6 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' - /flows/instances/{slug}/set_background/: - post: - operationId: flows_instances_set_background_create - description: Set Flow background - parameters: - - in: path - name: slug - schema: - type: string - description: Visible in the URL. - required: true - tags: - - flows - requestBody: - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/FileUploadRequest' - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' - /flows/instances/{slug}/set_background_url/: - post: - operationId: flows_instances_set_background_url_create - description: Set Flow background (as URL) - parameters: - - in: path - name: slug - schema: - type: string - description: Visible in the URL. - required: true - tags: - - flows - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FilePathRequest' - required: true - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' /flows/instances/{slug}/used_by/: get: operationId: flows_instances_used_by_list @@ -8172,7 +8164,7 @@ paths: content: multipart/form-data: schema: - $ref: '#/components/schemas/FileUploadRequest' + $ref: '#/components/schemas/FlowUploadRequest' security: - authentik: [] responses: @@ -20651,61 +20643,6 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' - /sources/all/{slug}/set_icon/: - post: - operationId: sources_all_set_icon_create - description: Set source icon - parameters: - - in: path - name: slug - schema: - type: string - description: Internal source name, used in URLs. - required: true - tags: - - sources - requestBody: - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/FileUploadRequest' - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' - /sources/all/{slug}/set_icon_url/: - post: - operationId: sources_all_set_icon_url_create - description: Set source icon (as URL) - parameters: - - in: path - name: slug - schema: - type: string - description: Internal source name, used in URLs. - required: true - tags: - - sources - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FilePathRequest' - required: true - security: - - authentik: [] - responses: - '200': - description: Success - '400': - description: Bad request - '403': - $ref: '#/components/responses/GenericErrorResponse' /sources/all/{slug}/used_by/: get: operationId: sources_all_used_by_list @@ -32805,6 +32742,7 @@ components: - authentik.endpoints.connectors.agent - authentik.enterprise - authentik.events + - authentik.admin.files - authentik.flows - authentik.outposts - authentik.policies.dummy @@ -32951,10 +32889,10 @@ components: format: uri meta_icon: type: string + meta_icon_url: + type: string nullable: true - description: |- - Get the URL to the App Icon image. If the name is /static or starts with http - it is returned as-is + description: Get the URL to the App Icon image readOnly: true meta_description: type: string @@ -32967,7 +32905,7 @@ components: required: - backchannel_providers_obj - launch_url - - meta_icon + - meta_icon_url - name - pk - provider_obj @@ -33034,6 +32972,8 @@ components: meta_launch_url: type: string format: uri + meta_icon: + type: string meta_description: type: string meta_publisher: @@ -37290,25 +37230,35 @@ components: minLength: 1 required: - object_pk - FilePathRequest: + FileList: type: object - description: Serializer to upload file + description: Base serializer class which doesn't implement create/update methods properties: + name: + type: string + mime_type: + type: string url: type: string - minLength: 1 required: + - mime_type + - name - url FileUploadRequest: type: object - description: Serializer to upload file + description: Base serializer class which doesn't implement create/update methods properties: file: type: string format: binary - clear: - type: boolean - default: false + name: + type: string + usage: + type: string + minLength: 1 + default: media + required: + - file Flow: type: object description: Flow Serializer @@ -37338,9 +37288,10 @@ components: flow is redirect to when an un-authenticated user visits authentik. background: type: string - description: |- - Get the URL to the background image. If the name is /static or starts with http - it is returned as-is + description: Background shown during execution + background_url: + type: string + description: Get the URL to the background image readOnly: true stages: type: array @@ -37381,7 +37332,7 @@ components: description: Required level of authentication and authorization to access a flow. required: - - background + - background_url - cache_count - designation - export_url @@ -37576,6 +37527,9 @@ components: - $ref: '#/components/schemas/FlowDesignationEnum' description: Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik. + background: + type: string + description: Background shown during execution policy_engine_mode: $ref: '#/components/schemas/PolicyEngineMode' compatibility_mode: @@ -37626,11 +37580,9 @@ components: - $ref: '#/components/schemas/FlowDesignationEnum' description: Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik. - background: + background_url: type: string - description: |- - Get the URL to the background image. If the name is /static or starts with http - it is returned as-is + description: Get the URL to the background image readOnly: true policy_engine_mode: $ref: '#/components/schemas/PolicyEngineMode' @@ -37650,7 +37602,7 @@ components: description: Configure what should happen when a flow denies access to a user. required: - - background + - background_url - designation - export_url - name @@ -37779,6 +37731,16 @@ components: - order - stage - target + FlowUploadRequest: + type: object + description: Serializer to upload file + properties: + file: + type: string + format: binary + clear: + type: boolean + default: false FooterLink: type: object description: Links returned in Config API @@ -39467,6 +39429,8 @@ components: type: string icon: type: string + icon_url: + type: string readOnly: true group_matching_mode: allOf: @@ -39517,7 +39481,7 @@ components: required: - component - connectivity - - icon + - icon_url - managed - meta_model_name - name @@ -39642,6 +39606,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -40135,6 +40101,8 @@ components: type: string icon: type: string + icon_url: + type: string readOnly: true server_uri: type: string @@ -40222,7 +40190,7 @@ components: - base_dn - component - connectivity - - icon + - icon_url - managed - meta_model_name - name @@ -40347,6 +40315,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string server_uri: type: string minLength: 1 @@ -41947,6 +41917,8 @@ components: type: string icon: type: string + icon_url: + type: string nullable: true readOnly: true group_matching_mode: @@ -42007,7 +41979,7 @@ components: - callback_url - component - consumer_key - - icon + - icon_url - managed - meta_model_name - name @@ -42133,6 +42105,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -45197,6 +45171,8 @@ components: meta_launch_url: type: string format: uri + meta_icon: + type: string meta_description: type: string meta_publisher: @@ -46017,6 +45993,9 @@ components: - $ref: '#/components/schemas/FlowDesignationEnum' description: Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik. + background: + type: string + description: Background shown during execution policy_engine_mode: $ref: '#/components/schemas/PolicyEngineMode' compatibility_mode: @@ -46511,6 +46490,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -46714,6 +46695,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string server_uri: type: string minLength: 1 @@ -47155,6 +47138,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -47426,6 +47411,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -48025,6 +48012,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -48493,6 +48482,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string bot_username: type: string minLength: 1 @@ -48969,6 +48960,8 @@ components: type: string icon: type: string + icon_url: + type: string readOnly: true group_matching_mode: allOf: @@ -48992,7 +48985,7 @@ components: description: Plex token used to check friends required: - component - - icon + - icon_url - managed - meta_model_name - name @@ -49117,6 +49110,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -51460,6 +51455,8 @@ components: type: string icon: type: string + icon_url: + type: string readOnly: true group_matching_mode: allOf: @@ -51531,7 +51528,7 @@ components: type: boolean required: - component - - icon + - icon_url - managed - meta_model_name - name @@ -51657,6 +51654,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string group_matching_mode: allOf: - $ref: '#/components/schemas/GroupMatchingModeEnum' @@ -53011,14 +53010,14 @@ components: type: string icon: type: string + icon_url: + type: string nullable: true - description: |- - Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is + description: Get the URL to the source icon readOnly: true required: - component - - icon + - icon_url - managed - meta_model_name - name @@ -53645,6 +53644,8 @@ components: type: string icon: type: string + icon_url: + type: string nullable: true readOnly: true bot_username: @@ -53660,7 +53661,7 @@ components: required: - bot_username - component - - icon + - icon_url - managed - meta_model_name - name @@ -53785,6 +53786,8 @@ components: user_path_template: type: string minLength: 1 + icon: + type: string bot_username: type: string minLength: 1 @@ -54198,6 +54201,7 @@ components: - cascade_many - set_null - set_default + - left_dangling type: string User: type: object diff --git a/scripts/generate_config.py b/scripts/generate_config.py index 37e265260c..dfe84cf9f0 100755 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -32,16 +32,14 @@ def generate_local_config() -> dict[str, Any]: } }, "storage": { - "media": { - "backend": "file", - "s3": { - "endpoint": "http://localhost:8020", - "access_key": "accessKey1", - "secret_key": "secretKey1", - "bucket_name": "authentik-media", - "custom_domain": "localhost:8020/authentik-media", - "secure_urls": False, - }, + "backend": "file", + "s3": { + "endpoint": "http://localhost:8020", + "access_key": "accessKey1", + "secret_key": "secretKey1", + "bucket_name": "authentik-media", + "custom_domain": "localhost:8020/authentik-media", + "secure_urls": False, }, }, "tenants": { diff --git a/scripts/generate_docker_compose.py b/scripts/generate_docker_compose.py index 17bb08cae2..086593e6a2 100644 --- a/scripts/generate_docker_compose.py +++ b/scripts/generate_docker_compose.py @@ -42,7 +42,7 @@ base = { "image": authentik_image, "ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"], "restart": "unless-stopped", - "volumes": ["./media:/media", "./custom-templates:/templates"], + "volumes": ["./media:/data/media", "./custom-templates:/templates"], }, "worker": { "command": "worker", @@ -62,7 +62,7 @@ base = { "user": "root", "volumes": [ "/var/run/docker.sock:/var/run/docker.sock", - "./media:/media", + "./media:/data/media", "./certs:/certs", "./custom-templates:/templates", ], diff --git a/web/src/admin/AdminInterface/AboutModal.ts b/web/src/admin/AdminInterface/AboutModal.ts index 0dc9d32e10..0b57b29106 100644 --- a/web/src/admin/AdminInterface/AboutModal.ts +++ b/web/src/admin/AdminInterface/AboutModal.ts @@ -6,6 +6,7 @@ import { globalAK } from "#common/global"; import { ModalButton } from "#elements/buttons/ModalButton"; import { WithBrandConfig } from "#elements/mixins/branding"; import { WithLicenseSummary } from "#elements/mixins/license"; +import { renderImage } from "#elements/utils/images"; import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthentik/api"; @@ -30,6 +31,12 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) .pf-c-about-modal-box__hero { background-image: url("/static/dist/assets/images/flow_background.jpg"); } + .pf-c-about-modal-box__brand { + --pf-c-about-modal-box__brand-image--Height: 6.25rem; + } + .pf-c-about-modal-box__brand i { + font-size: var(--pf-c-about-modal-box__brand-image--Height); + } `, ]; @@ -88,11 +95,11 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) aria-labelledby="modal-title" >
- ${msg( + ${renderImage( + this.brandingFavicon, + msg("authentik Logo"), + "pf-c-about-modal-box__brand-image", + )}
`; diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index 9113a0bb43..a1fa47c3e4 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -8,6 +8,7 @@ import "#elements/forms/HorizontalFormElement"; import "#elements/forms/SearchSelect/index"; import "#components/ak-text-input"; import "#components/ak-switch-input"; +import "#components/ak-file-search-input"; import { DEFAULT_CONFIG } from "#common/api/config"; import { DefaultBrand } from "#common/ui/config"; @@ -20,6 +21,7 @@ import { AKLabel } from "#components/ak-label"; import { certificateProvider, certificateSelector } from "#admin/brands/Certificates"; import { + AdminFileListUsageEnum, Application, Brand, CoreApi, @@ -50,9 +52,9 @@ export class BrandForm extends ModelForm { async send(data: Brand): Promise { data.attributes ??= {}; if (this.instance?.brandUuid) { - return new CoreApi(DEFAULT_CONFIG).coreBrandsUpdate({ + return new CoreApi(DEFAULT_CONFIG).coreBrandsPartialUpdate({ brandUuid: this.instance.brandUuid, - brandRequest: data, + patchedBrandRequest: data, }); } return new CoreApi(DEFAULT_CONFIG).coreBrandsCreate({ @@ -94,44 +96,35 @@ export class BrandForm extends ModelForm { help=${msg("Branding shown in page title and several other places.")} > - + > - + > - + >
diff --git a/web/src/admin/files/FileListPage.ts b/web/src/admin/files/FileListPage.ts new file mode 100644 index 0000000000..5f2704a43e --- /dev/null +++ b/web/src/admin/files/FileListPage.ts @@ -0,0 +1,137 @@ +import "#admin/files/FileUploadForm"; +import "#elements/buttons/SpinnerButton/index"; +import "#elements/forms/DeleteBulkForm"; +import "#elements/forms/ModalForm"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { DEFAULT_CONFIG } from "#common/api/config"; + +import { PaginatedResponse, TableColumn } from "#elements/table/Table"; +import { TablePage } from "#elements/table/TablePage"; +import { SlottedTemplateResult } from "#elements/types"; + +import { AdminApi, AdminFileListUsageEnum } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export interface FileItem { + name: string; + url: string; + mimeType: string; +} + +export type FileListOrderKey = "name" | "mimeType"; + +@customElement("ak-files-list") +export class FileListPage extends TablePage { + public override checkbox = true; + public override clearOnRefresh = true; + + protected override searchEnabled = true; + public override pageTitle = msg("Files"); + public override pageDescription = msg("Manage uploaded files."); + public override pageIcon = "pf-icon pf-icon-folder-open"; + + @property({ type: String, useDefault: true }) + public order: FileListOrderKey = "name"; + + async apiEndpoint(): Promise> { + const api = new AdminApi(DEFAULT_CONFIG); + // Cast necessary: API returns File objects but we only use name, url, and mimeType properties + const items = (await api.adminFileList({ + usage: AdminFileListUsageEnum.Media, + manageableOnly: true, + ...(this.search ? { search: this.search } : {}), + })) as unknown as FileItem[]; + + // Wrap array response in paginated response structure + return { + pagination: { + next: 0, + previous: 0, + count: items.length, + current: 1, + totalPages: 1, + startIndex: 1, + endIndex: items.length, + }, + results: items, + }; + } + + protected columns: TableColumn[] = [ + [msg("Name"), "name"], + [msg("Type")], + [msg("Actions"), null, msg("Row Actions")], + ]; + + renderToolbarSelected(): TemplateResult { + const disabled = !this.selectedElements.length; + const count = this.selectedElements.length; + return html` { + return [ + { key: msg("Name"), value: item.name }, + { key: msg("Type"), value: item.mimeType }, + ]; + }} + .usedBy=${(item: FileItem) => { + return new AdminApi(DEFAULT_CONFIG).adminFileUsedByList({ + name: item.name, + }); + }} + .delete=${(item: FileItem) => { + return new AdminApi(DEFAULT_CONFIG).adminFileDestroy({ + name: item.name, + usage: AdminFileListUsageEnum.Media, + }); + }} + > + + `; + } + + row(item: FileItem): SlottedTemplateResult[] { + return [ + html`
${item.name}
`, + html`
${item.mimeType || msg("-")}
`, + html`
`, + ]; + } + + protected renderObjectCreate(): TemplateResult { + return html` + + ${msg("Upload")} + ${msg("Upload File")} + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-files-list": FileListPage; + } +} diff --git a/web/src/admin/files/FileUploadForm.ts b/web/src/admin/files/FileUploadForm.ts new file mode 100644 index 0000000000..94430badad --- /dev/null +++ b/web/src/admin/files/FileUploadForm.ts @@ -0,0 +1,126 @@ +import "#elements/forms/HorizontalFormElement"; + +import { DEFAULT_CONFIG } from "#common/api/config"; +import { MessageLevel } from "#common/messages"; + +import { Form } from "#elements/forms/Form"; +import { PreventFormSubmit } from "#elements/forms/helpers"; +import { showMessage } from "#elements/messages/MessageContainer"; + +import { AdminApi, AdminFileListUsageEnum } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +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; + +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"), + ); + } +} + +@customElement("ak-file-upload-form") +export class FileUploadForm extends Form> { + @property({ type: String, useDefault: true }) + public usage: AdminFileListUsageEnum = AdminFileListUsageEnum.Media; + + @state() + protected selectedFile: File | null = null; + + #formRef = createRef(); + + #fileChangeListener = (e: Event) => { + const input = e.target as HTMLInputElement; + if (input.files?.length) { + this.selectedFile = input.files[0]; + } else { + this.selectedFile = null; + } + }; + + protected clearFileInput() { + this.selectedFile = null; + this.#formRef.value?.reset(); + } + + public override async send(data: Record): Promise { + if (!this.selectedFile) { + 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() : ""; + + // If custom name provided, validate and append original extension + let finalName = this.selectedFile.name; + if (customName) { + assertValidFileName(customName); + const ext = this.selectedFile.name.substring(this.selectedFile.name.lastIndexOf(".")); + finalName = customName + ext; + } + + return api + .adminFileCreate({ + file: this.selectedFile, + name: finalName, + usage: this.usage, + }) + .then(() => { + showMessage({ + level: MessageLevel.success, + message: msg("File uploaded successfully"), + }); + + this.reset(); + }) + .finally(() => { + this.clearFileInput(); + }); + } + + renderForm() { + return html` +
+ + + + + +

+ ${msg( + "Optionally rename the file (without extension). Leave empty to keep the original filename.", + )} +

+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-file-upload-form": FileUploadForm; + } +} diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index b57b0f3d3c..e950b589ac 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -1,3 +1,4 @@ +import "#components/ak-file-search-input"; import "#components/ak-slug-input"; import "#elements/forms/FormGroup"; import "#elements/forms/HorizontalFormElement"; @@ -6,12 +7,13 @@ import "#elements/forms/Radio"; import { DEFAULT_CONFIG } from "#common/api/config"; import { ModelForm } from "#elements/forms/ModelForm"; -import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities"; +import { WithCapabilitiesConfig } from "#elements/mixins/capabilities"; import { DesignationToLabel, LayoutToLabel } from "#admin/flows/utils"; import { policyEngineModes } from "#admin/policies/PolicyEngineModes"; import { + AdminFileListUsageEnum, DeniedActionEnum, Flow, FlowDesignationEnum, @@ -21,18 +23,16 @@ import { import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum.js"; import { msg } from "@lit/localize"; -import { html, nothing, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { html, TemplateResult } from "lit"; +import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @customElement("ak-flow-form") export class FlowForm extends WithCapabilitiesConfig(ModelForm) { async loadInstance(pk: string): Promise { - const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({ + return new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({ slug: pk, }); - this.clearBackground = false; - return flow; } getSuccessMessage(): string { @@ -41,40 +41,16 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm) { : msg("Successfully created flow."); } - @property({ type: Boolean }) - clearBackground = false; - async send(data: Flow): Promise { - let flow: Flow; if (this.instance) { - flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesUpdate({ + return new FlowsApi(DEFAULT_CONFIG).flowsInstancesUpdate({ slug: this.instance.slug, flowRequest: data, }); - } else { - flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesCreate({ - flowRequest: data, - }); } - - if (this.can(CapabilitiesEnum.CanSaveMedia)) { - const icon = this.files().get("background"); - if (icon || this.clearBackground) { - await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({ - slug: flow.slug, - file: icon, - clear: this.clearBackground, - }); - } - } else { - await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundUrlCreate({ - slug: flow.slug, - filePathRequest: { - url: data.background || "", - }, - }); - } - return flow; + return new FlowsApi(DEFAULT_CONFIG).flowsInstancesCreate({ + flowRequest: data, + }); } renderForm(): TemplateResult { @@ -324,69 +300,14 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm) { - ${this.can(CapabilitiesEnum.CanSaveMedia) - ? html` - - ${this.instance?.background - ? html` -

- ${msg("Currently set to:")} - ${this.instance?.background} -

- ` - : nothing} - -

- ${msg("Background shown during execution.")} -

-
- ${this.instance?.background - ? html` - - -

- ${msg("Delete currently set background image.")} -

-
- ` - : nothing}` - : html` - -

- ${msg("Background shown during execution.")} -

-
`} +
`; } diff --git a/web/src/admin/sources/kerberos/KerberosSourceForm.ts b/web/src/admin/sources/kerberos/KerberosSourceForm.ts index 39a28048d0..4ae12350be 100644 --- a/web/src/admin/sources/kerberos/KerberosSourceForm.ts +++ b/web/src/admin/sources/kerberos/KerberosSourceForm.ts @@ -2,6 +2,7 @@ import "#admin/common/ak-flow-search/ak-source-flow-search"; import "#components/ak-secret-text-input"; import "#components/ak-secret-textarea-input"; import "#components/ak-slug-input"; +import "#components/ak-file-search-input"; import "#components/ak-switch-input"; import "#components/ak-text-input"; import "#components/ak-textarea-input"; @@ -12,15 +13,14 @@ import "#elements/forms/SearchSelect/index"; import { propertyMappingsProvider, propertyMappingsSelector } from "./KerberosSourceFormHelpers.js"; -import { config, DEFAULT_CONFIG } from "#common/api/config"; - -import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities"; +import { DEFAULT_CONFIG } from "#common/api/config"; import { iconHelperText, placeholderHelperText } from "#admin/helperText"; import { BaseSourceForm } from "#admin/sources/BaseSourceForm"; import { GroupMatchingModeToLabel, UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; import { + AdminFileListUsageEnum, FlowsInstancesListDesignationEnum, GroupMatchingModeEnum, KadminTypeEnum, @@ -31,54 +31,29 @@ import { } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html, nothing, TemplateResult } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { html, TemplateResult } from "lit"; +import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @customElement("ak-source-kerberos-form") -export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { +export class KerberosSourceForm extends BaseSourceForm { async loadInstance(pk: string): Promise { - const source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosRetrieve({ + return new SourcesApi(DEFAULT_CONFIG).sourcesKerberosRetrieve({ slug: pk, }); - this.clearIcon = false; - return source; } - @state() - clearIcon = false; - async send(data: KerberosSource): Promise { - let source: KerberosSource; if (this.instance) { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosPartialUpdate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesKerberosPartialUpdate({ slug: this.instance.slug, patchedKerberosSourceRequest: data, }); } else { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosCreate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesKerberosCreate({ kerberosSourceRequest: data as unknown as KerberosSourceRequest, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { - const icon = this.files().get("icon"); - if (icon || this.clearIcon) { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ - slug: source.slug, - file: icon, - clear: this.clearIcon, - }); - } - } else { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ - slug: source.slug, - filePathRequest: { - url: data.icon || "", - }, - }); - } - return source; } renderForm(): TemplateResult { @@ -390,52 +365,14 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm - ${this.can(CapabilitiesEnum.CanSaveMedia) - ? html` - - ${this.instance?.icon - ? html` -

- ${msg("Currently set to:")} ${this.instance?.icon} -

- ` - : nothing} -
- ${this.instance?.icon - ? html` - - -

- ${msg("Delete currently set icon.")} -

-
- ` - : nothing}` - : html` - -

${iconHelperText}

-
`} + `; } } diff --git a/web/src/admin/sources/oauth/OAuthSourceForm.ts b/web/src/admin/sources/oauth/OAuthSourceForm.ts index 0a2cbff5c4..4f2194c3e9 100644 --- a/web/src/admin/sources/oauth/OAuthSourceForm.ts +++ b/web/src/admin/sources/oauth/OAuthSourceForm.ts @@ -1,4 +1,5 @@ import "#admin/common/ak-flow-search/ak-source-flow-search"; +import "#components/ak-file-search-input"; import "#components/ak-radio-input"; import "#components/ak-secret-textarea-input"; import "#components/ak-slug-input"; @@ -11,10 +12,9 @@ import "#elements/forms/SearchSelect/index"; import { propertyMappingsProvider, propertyMappingsSelector } from "./OAuthSourceFormHelpers.js"; -import { config, DEFAULT_CONFIG } from "#common/api/config"; +import { DEFAULT_CONFIG } from "#common/api/config"; import { CodeMirrorMode } from "#elements/CodeMirror"; -import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities"; import { SlottedTemplateResult } from "#elements/types"; import { iconHelperText, placeholderHelperText } from "#admin/helperText"; @@ -23,6 +23,7 @@ import { BaseSourceForm } from "#admin/sources/BaseSourceForm"; import { GroupMatchingModeToLabel, UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; import { + AdminFileListUsageEnum, AuthorizationCodeAuthMethodEnum, FlowsInstancesListDesignationEnum, GroupMatchingModeEnum, @@ -37,7 +38,7 @@ import { import { msg } from "@lit/localize"; import { html, nothing, PropertyValues, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; const authorizationCodeAuthMethodOptions = [ @@ -69,13 +70,12 @@ const pkceMethodOptions = [ ]; @customElement("ak-source-oauth-form") -export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { +export class OAuthSourceForm extends BaseSourceForm { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({ slug: pk, }); this.providerType = source.type; - this.clearIcon = false; return source; } @@ -87,41 +87,18 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm { data.providerType = (this.providerType?.name || "") as ProviderTypeEnum; - let source: OAuthSource; if (this.instance) { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({ slug: this.instance.slug, patchedOAuthSourceRequest: data, }); } else { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthCreate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesOauthCreate({ oAuthSourceRequest: data as unknown as OAuthSourceRequest, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { - const icon = this.files().get("icon"); - if (icon || this.clearIcon) { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ - slug: source.slug, - file: icon, - clear: this.clearIcon, - }); - } - } else { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ - slug: source.slug, - filePathRequest: { - url: data.icon || "", - }, - }); - } - return source; } fetchProviderType(v: string | undefined) { @@ -418,54 +395,14 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm

${placeholderHelperText}

- ${this.can(CapabilitiesEnum.CanSaveMedia) - ? html` - - ${this.instance?.icon - ? html` -

- ${msg("Currently set to:")} ${this.instance?.icon} -

- ` - : nothing} -
- ${this.instance?.icon - ? html` - - -

- ${msg("Delete currently set icon.")} -

-
- ` - : nothing}` - : html` - -

${iconHelperText}

-
`} +
diff --git a/web/src/admin/sources/plex/PlexSourceForm.ts b/web/src/admin/sources/plex/PlexSourceForm.ts index d53187d109..6795836221 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -1,4 +1,5 @@ import "#admin/common/ak-flow-search/ak-source-flow-search"; +import "#components/ak-file-search-input"; import "#components/ak-slug-input"; import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider"; import "#elements/ak-dual-select/ak-dual-select-provider"; @@ -12,14 +13,13 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { PlexAPIClient, PlexResource, popupCenterScreen } from "#common/helpers/plex"; import { ascii_letters, digits, randomString } from "#common/utils"; -import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities"; - import { iconHelperText, placeholderHelperText } from "#admin/helperText"; import { policyEngineModes } from "#admin/policies/PolicyEngineModes"; import { BaseSourceForm } from "#admin/sources/BaseSourceForm"; import { GroupMatchingModeToLabel, UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; import { + AdminFileListUsageEnum, FlowsInstancesListDesignationEnum, GroupMatchingModeEnum, PlexSource, @@ -28,25 +28,21 @@ import { } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html, nothing, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @customElement("ak-source-plex-form") -export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { +export class PlexSourceForm extends BaseSourceForm { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({ slug: pk, }); this.plexToken = source.plexToken; this.loadServers(); - this.clearIcon = false; return source; } - @state() - clearIcon = false; - @property() plexToken?: string; @@ -61,35 +57,16 @@ export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm { data.plexToken = this.plexToken || ""; - let source: PlexSource; if (this.instance?.pk) { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ slug: this.instance.slug, plexSourceRequest: data, }); } else { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ plexSourceRequest: data, }); } - if (this.can(CapabilitiesEnum.CanSaveMedia)) { - const icon = this.files().get("icon"); - if (icon || this.clearIcon) { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ - slug: source.slug, - file: icon, - clear: this.clearIcon, - }); - } - } else { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ - slug: source.slug, - filePathRequest: { - url: data.icon || "", - }, - }); - } - return source; } async doAuth(): Promise { @@ -307,52 +284,14 @@ export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm

${placeholderHelperText}

- ${this.can(CapabilitiesEnum.CanSaveMedia) - ? html` - - ${this.instance?.icon - ? html` -

- ${msg("Currently set to:")} ${this.instance?.icon} -

- ` - : nothing} -
- ${this.instance?.icon - ? html` - - -

- ${msg("Delete currently set icon.")} -

-
- ` - : nothing}` - : html` - -

${iconHelperText}

-
`} +
diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index ddf0a18020..86b327098e 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -1,5 +1,6 @@ import "#admin/common/ak-crypto-certificate-search"; import "#admin/common/ak-flow-search/ak-source-flow-search"; +import "#components/ak-file-search-input"; import "#components/ak-slug-input"; import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider"; import "#elements/forms/FormGroup"; @@ -9,9 +10,7 @@ import "#elements/utils/TimeDeltaHelp"; import { propertyMappingsProvider, propertyMappingsSelector } from "./SAMLSourceFormHelpers.js"; -import { config, DEFAULT_CONFIG } from "#common/api/config"; - -import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities"; +import { DEFAULT_CONFIG } from "#common/api/config"; import { type AkCryptoCertificateSearch } from "#admin/common/ak-crypto-certificate-search"; import { iconHelperText, placeholderHelperText } from "#admin/helperText"; @@ -20,6 +19,7 @@ import { BaseSourceForm } from "#admin/sources/BaseSourceForm"; import { GroupMatchingModeToLabel, UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; import { + AdminFileListUsageEnum, BindingTypeEnum, DigestAlgorithmEnum, FlowsInstancesListDesignationEnum, @@ -37,10 +37,7 @@ import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @customElement("ak-source-saml-form") -export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { - @state() - clearIcon = false; - +export class SAMLSourceForm extends BaseSourceForm { @state() hasSigningCert = false; @@ -51,44 +48,22 @@ export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm { - const source = await new SourcesApi(DEFAULT_CONFIG).sourcesSamlRetrieve({ + return new SourcesApi(DEFAULT_CONFIG).sourcesSamlRetrieve({ slug: pk, }); - this.clearIcon = false; - return source; } async send(data: SAMLSource): Promise { - let source: SAMLSource; if (this.instance) { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesSamlUpdate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesSamlUpdate({ slug: this.instance.slug, sAMLSourceRequest: data, }); } else { - source = await new SourcesApi(DEFAULT_CONFIG).sourcesSamlCreate({ + return new SourcesApi(DEFAULT_CONFIG).sourcesSamlCreate({ sAMLSourceRequest: data, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { - const icon = this.files().get("icon"); - if (icon || this.clearIcon) { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ - slug: source.slug, - file: icon, - clear: this.clearIcon, - }); - } - } else { - await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ - slug: source.slug, - filePathRequest: { - url: data.icon || "", - }, - }); - } - return source; } renderHasSigningCert(): TemplateResult { @@ -259,52 +234,14 @@ export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm - ${this.can(CapabilitiesEnum.CanSaveMedia) - ? html` - - ${this.instance?.icon - ? html` -

- ${msg("Currently set to:")} ${this.instance?.icon} -

- ` - : nothing} -
- ${this.instance?.icon - ? html` - - -

- ${msg("Delete currently set icon.")} -

-
- ` - : nothing}` - : html` - -

${iconHelperText}

-
`} +
diff --git a/web/src/admin/sources/telegram/TelegramSourceForm.ts b/web/src/admin/sources/telegram/TelegramSourceForm.ts index c795abdaf3..2e409609cc 100644 --- a/web/src/admin/sources/telegram/TelegramSourceForm.ts +++ b/web/src/admin/sources/telegram/TelegramSourceForm.ts @@ -2,8 +2,6 @@ import { propertyMappingsProvider, propertyMappingsSelector } from "./TelegramSo import { DEFAULT_CONFIG } from "#common/api/config"; -import { WithCapabilitiesConfig } from "#elements/mixins/capabilities"; - import { policyEngineModes } from "#admin/policies/PolicyEngineModes"; import { BaseSourceForm } from "#admin/sources/BaseSourceForm"; import { UserMatchingModeToLabel } from "#admin/sources/oauth/utils"; @@ -22,7 +20,7 @@ import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @customElement("ak-source-telegram-form") -export class TelegramSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { +export class TelegramSourceForm extends BaseSourceForm { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesTelegramRetrieve({ slug: pk, diff --git a/web/src/admin/users/UserApplicationTable.ts b/web/src/admin/users/UserApplicationTable.ts index 59223ff070..e51c0ee9c0 100644 --- a/web/src/admin/users/UserApplicationTable.ts +++ b/web/src/admin/users/UserApplicationTable.ts @@ -40,7 +40,7 @@ export class UserApplicationTable extends Table { row(item: Application): SlottedTemplateResult[] { return [ - html``, + html``, html`
${item.name}
${item.metaPublisher ? html`${item.metaPublisher}` : nothing} diff --git a/web/src/components/ak-file-search-input.ts b/web/src/components/ak-file-search-input.ts new file mode 100644 index 0000000000..47620a2724 --- /dev/null +++ b/web/src/components/ak-file-search-input.ts @@ -0,0 +1,153 @@ +import "#elements/forms/SearchSelect/index"; + +import { DEFAULT_CONFIG } from "#common/api/config"; +import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; + +import { AKElement } from "#elements/Base"; + +import { AKLabel } from "#components/ak-label"; + +import { AdminApi, AdminFileListUsageEnum } from "@goauthentik/api"; +import { IDGenerator } from "@goauthentik/core/id"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +interface FileItem { + name: string; + url: string; + mime_type: string; + size: number; + usage: string; +} + +const renderElement = (item: FileItem) => item.name; +const renderValue = (item?: FileItem | null) => item?.name; + +/** + * File Search Input Component + * + * Search/select dropdown for files from authentik.admin.files storage. + * Supports uploaded files, static files, and external URLs/Font Awesome icons via PassthroughBackend. + */ +@customElement("ak-file-search-input") +export class AKFileSearchInput extends AKElement { + // Render into the lightDOM + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + public name: string | null = null; + + @property({ type: String }) + public label: string | null = null; + + @property({ type: String }) + public value: string = ""; + + @property({ type: Boolean }) + public required = false; + + @property({ type: Boolean }) + public blankable = false; + + @property({ type: String }) + public help: string | null = null; + + @property({ type: String, useDefault: true }) + public usage: AdminFileListUsageEnum = AdminFileListUsageEnum.Media; + + @property({ type: String, reflect: false }) + public fieldID?: string = IDGenerator.elementID().toString(); + + #selected = (item: FileItem) => { + return this.value === item.name; + }; + + public override firstUpdated() { + // If we have a value but it's not in the fetched results (like fa:// or custom URL), + // the search-select won't show it. We need to add it to the initial fetch. + if (this.value) { + // Search-select will call #fetch and then try to select using #selected + // And then if the value isn't found in results, creatable mode will handle it + } + } + + async #fetch(query?: string): Promise { + const api = new AdminApi(DEFAULT_CONFIG); + return api + .adminFileList({ + usage: this.usage as AdminFileListUsageEnum, + ...(query ? { search: query } : {}), + }) + .then((response) => { + // Cast necessary: API returns File objects but we only use name, url, mime_type, size, and usage properties + const fileResponse = response as unknown as FileItem[]; + + if (!fileResponse || !Array.isArray(fileResponse)) { + console.error("Invalid response format from files API", fileResponse); + return []; + } + + let results = fileResponse; + + // If we have a current value and it's not in the results (e.g., fa:// or custom URL), + // add it as a synthetic item so it shows up as selected + if (this.value && !results.find((item) => item.name === this.value)) { + results = [ + { + name: this.value, + url: this.value, + mime_type: "", + size: 0, + usage: this.usage, + }, + ...results, + ]; + } + + return results; + }) + .catch(async (error) => { + const parsedError = await parseAPIResponseError(error); + console.error(msg("Failed to fetch files"), pluckErrorDetail(parsedError)); + return []; + }); + } + + render() { + return html` +
+ ${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)} +
+ + + + ${this.help + ? html`

${this.help}

` + : html`

+ ${msg( + "You can also enter a URL (https://...), Font Awesome icon (fa://fa-icon-name), or upload a new file.", + )} +

`} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-file-search-input": AKFileSearchInput; + } +} diff --git a/web/src/components/ak-page-navbar.ts b/web/src/components/ak-page-navbar.ts index 94b76e845d..3cbefda3f3 100644 --- a/web/src/components/ak-page-navbar.ts +++ b/web/src/components/ak-page-navbar.ts @@ -9,7 +9,7 @@ import { AKElement } from "#elements/Base"; import { WithBrandConfig } from "#elements/mixins/branding"; import { WithSession } from "#elements/mixins/session"; import { isAdminRoute } from "#elements/router/utils"; -import { themeImage } from "#elements/utils/images"; +import { renderImage } from "#elements/utils/images"; import { msg } from "@lit/localize"; import { css, CSSResult, html, nothing, TemplateResult } from "lit"; @@ -211,6 +211,12 @@ export class AKPageNavbar & img { height: 100%; } + + & i { + font-size: var(--ak-brand-logo-height); + height: var(--ak-brand-logo-height); + line-height: var(--ak-brand-logo-height); + } } .sidebar-trigger, @@ -355,11 +361,7 @@ export class AKPageNavbar
diff --git a/web/src/elements/AppIcon.css b/web/src/elements/AppIcon.css index a2055b3acd..0cf750df1f 100644 --- a/web/src/elements/AppIcon.css +++ b/web/src/elements/AppIcon.css @@ -14,6 +14,10 @@ width: calc(var(--icon-height) + var(--icon-border) + var(--icon-border)); } +.icon-wrapper { + display: contents; +} + :host([size="pf-m-lg"]) { --icon-height: 4rem; --icon-border: 0.25rem; diff --git a/web/src/elements/AppIcon.ts b/web/src/elements/AppIcon.ts index 08114dd648..185d52a636 100644 --- a/web/src/elements/AppIcon.ts +++ b/web/src/elements/AppIcon.ts @@ -2,6 +2,7 @@ import { PFSize } from "#common/enums"; import Styles from "#elements/AppIcon.css"; import { AKElement } from "#elements/Base"; +import { FontAwesomeProtocol } from "#elements/utils/images"; import { msg, str } from "@lit/localize"; import { CSSResult, html, TemplateResult } from "lit"; @@ -17,7 +18,7 @@ export interface IAppIcon { @customElement("ak-app-icon") export class AppIcon extends AKElement implements IAppIcon { - public static readonly FontAwesomeProtocol = "fa://"; + public static readonly FontAwesomeProtocol = FontAwesomeProtocol; static styles: CSSResult[] = [PFFAIcons, Styles]; @@ -30,35 +31,51 @@ export class AppIcon extends AKElement implements IAppIcon { @property({ reflect: true }) public size: PFSize = PFSize.Medium; - render(): TemplateResult { + #wrap(icon: TemplateResult): TemplateResult { + // PatternFly's font awesome rules use descendant selectors (`* .fa-*`), + // so the icon needs at least one ancestor inside the shadow DOM to pick up those styles. + return html`${icon}`; + } + + override render(): TemplateResult { const applicationName = this.name ?? msg("Application"); const label = msg(str`${applicationName} Icon`); + // Check for Font Awesome icons (fa://fa-icon-name) if (this.icon?.startsWith(AppIcon.FontAwesomeProtocol)) { - return html``; + const iconClass = this.icon.slice(AppIcon.FontAwesomeProtocol.length); + return this.#wrap( + html``, + ); } const insignia = this.name?.charAt(0).toUpperCase() ?? "�"; + // Check for image URLs (http://, https://, or file paths) if (this.icon) { - return html`${insignia}`; + return this.#wrap( + html`${insignia}`, + ); } - return html`${insignia}`; + // Fallback to first letter insignia + return this.#wrap( + html`${insignia}`, + ); } } diff --git a/web/src/elements/forms/DeleteBulkForm.ts b/web/src/elements/forms/DeleteBulkForm.ts index bbd0800ba9..d099f63898 100644 --- a/web/src/elements/forms/DeleteBulkForm.ts +++ b/web/src/elements/forms/DeleteBulkForm.ts @@ -110,6 +110,9 @@ export class DeleteObjectsTable extends Table { case UsedByActionEnum.SetNull: consequence = msg("reference will be set to an empty value"); break; + case UsedByActionEnum.LeftDangling: + consequence = msg("reference will be left dangling"); + break; } return html`
  • ${msg(str`${ub.name} (${consequence})`)}
  • `; })} diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index d9f3d6f763..572b98a86f 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -270,7 +270,7 @@ export abstract class Form> extends AKElement { * * @deprecated Use `formatAPISuccessMessage` instead. */ - protected getSuccessMessage(): string | undefined { + public getSuccessMessage(): string | undefined { return this.successMessage; } diff --git a/web/src/elements/forms/ProxyForm.ts b/web/src/elements/forms/ProxyForm.ts index 3b92a083d2..b714ec35f7 100644 --- a/web/src/elements/forms/ProxyForm.ts +++ b/web/src/elements/forms/ProxyForm.ts @@ -1,3 +1,5 @@ +import "#admin/applications/ApplicationCheckAccessForm"; + import type { OwnPropertyRecord } from "#common/types"; import type { AKElement } from "#elements/Base"; diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index d182b28838..92410b25ec 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -85,6 +85,14 @@ export abstract class SearchSelectBase @property({ type: Boolean }) public blankable?: boolean; + /** + * Whether or not the component allows creating custom values not in the list + * @property + * @attr + */ + @property({ type: Boolean }) + public creatable?: boolean; + /** * An initial string to filter the search contents, * and the value of the input which further serves to restrict the search. @@ -163,6 +171,27 @@ export abstract class SearchSelectBase throw new PreventFormSubmit("SearchSelect has not yet loaded data", this); } + + // When the user types a value and submits the form without explicitly selecting + // an option (e.g., typing "fa://fa-shield-alt" and clicking Update without pressing Enter), + // the selectedObject may not be synced with the current input value. + // So, on form submission, check if the current input value differs from selectedObject + // and if so, create a synthetic object with the current value. + if (this.creatable) { + const view = this.renderRoot.querySelector("ak-search-select-view") as SearchSelectView; + const currentValue = view?.rawValue; + + if (currentValue) { + // Check if the current input value matches what we have selected + const selectedValue = this.selectedObject ? this.value(this.selectedObject) : null; + + if (selectedValue !== currentValue) { + // Input has changed but hasn't been committed yet so create synthetic object + this.selectedObject = { name: currentValue } as T; + } + } + } + return this.value(this.selectedObject) || ""; } @@ -234,6 +263,15 @@ export abstract class SearchSelectBase this.query = value; this.updateData()?.then(() => { + // If creatable, check if selectedObject's value matches the typed value exactly + if (this.creatable) { + const selectedValue = this.selectedObject ? this.value(this.selectedObject) : null; + if (selectedValue !== value) { + // No exact match so create a synthetic object with the raw value + // "synthetic" isn't an official term or anything, it's just called like that here + this.selectedObject = { name: value } as T; + } + } this.dispatchChangeEvent(this.selectedObject); }); }; @@ -262,6 +300,12 @@ export abstract class SearchSelectBase }) || null; if (!selected) { + if (this.creatable) { + // Create a synthetic object with the user's custom value + this.selectedObject = { name: value } as T; + this.dispatchChangeEvent(this.selectedObject); + return; + } console.warn(`ak-search-select: No corresponding object found for value (${value}`); } diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts index 977b4cafcb..25157bbb42 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -397,6 +397,9 @@ export class SearchSelectView extends AKElement implements ISearchSelectView { const newDisplayValue = this.findDisplayForValue(this.value); if (newDisplayValue) { this.displayValue = newDisplayValue; + } else { + // If no display value found (e.g., custom creatable value), use the value itself + this.displayValue = this.value; } } } diff --git a/web/src/elements/sync/SyncObjectForm.ts b/web/src/elements/sync/SyncObjectForm.ts index c2b8d9b94e..c29525f299 100644 --- a/web/src/elements/sync/SyncObjectForm.ts +++ b/web/src/elements/sync/SyncObjectForm.ts @@ -44,7 +44,7 @@ export class SyncObjectForm extends Form { return Promise.reject(); }; - getSuccessMessage(): string { + public override getSuccessMessage(): string { return msg("Successfully triggered sync."); } diff --git a/web/src/elements/tasks/ScheduleForm.ts b/web/src/elements/tasks/ScheduleForm.ts index 954e16fbe2..7ea14e1282 100644 --- a/web/src/elements/tasks/ScheduleForm.ts +++ b/web/src/elements/tasks/ScheduleForm.ts @@ -24,7 +24,7 @@ export class ScheduleForm extends ModelForm { }); } - getSuccessMessage(): string { + public override getSuccessMessage(): string { if (!this.instance) { return ""; } diff --git a/web/src/elements/utils/images.ts b/web/src/elements/utils/images.ts index 8fc1c6f632..8f6e77f8e6 100644 --- a/web/src/elements/utils/images.ts +++ b/web/src/elements/utils/images.ts @@ -1,7 +1,57 @@ -import { ResolvedUITheme } from "#common/theme"; +import { resolveUITheme, rootInterface } from "#common/theme"; -export function themeImage(rawPath: string, theme: ResolvedUITheme) { - return rawPath.replaceAll("%(theme)s", theme); +import type { AKElement } from "#elements/Base"; +import type { SlottedTemplateResult } from "#elements/types"; +import { ifPresent } from "#elements/utils/attributes"; + +import { html, nothing } from "lit"; + +export const FontAwesomeProtocol = "fa://"; + +export function themeImage(rawPath: string) { + const enabledTheme = rootInterface()?.activeTheme || resolveUITheme(); + + return rawPath.replaceAll("%(theme)s", enabledTheme); +} + +/** + * Renders an image that can be a regular URL, Font Awesome icon (fa://), or themed image + * + * @param imagePath - URL, fa:// icon, or path with %(theme)s placeholder + * @param alt - Alt text for the image + * @param className - CSS classes to apply + * @returns TemplateResult with either or element + */ +export function renderImage( + imagePath: string, + alt?: string, + className?: string, +): SlottedTemplateResult { + if (!imagePath) { + return nothing; + } + + // Handle Font Awesome icons (same logic as ak-app-icon) + if (imagePath.startsWith(FontAwesomeProtocol)) { + const classes = [ + className, + "font-awesome", + "fas", + imagePath.slice(FontAwesomeProtocol.length), + ] + .filter(Boolean) + .join(" "); + return html``; + } + + const src = themeImage(imagePath); + + return html`${ifPresent(alt)}`; } export function isDefaultAvatar(path?: string | null): boolean { diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index dc02bf9cc0..e6a358f6a9 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -24,7 +24,7 @@ import { WithBrandConfig } from "#elements/mixins/branding"; import { WithCapabilitiesConfig } from "#elements/mixins/capabilities"; import { LitPropertyRecord } from "#elements/types"; import { exportParts } from "#elements/utils/attributes"; -import { themeImage } from "#elements/utils/images"; +import { renderImage } from "#elements/utils/images"; import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base"; @@ -479,13 +479,7 @@ export class FlowExecutor part="main" > ${this.loading && this.challenge ? html`` diff --git a/web/src/standalone/api-browser/index.entrypoint.css b/web/src/standalone/api-browser/index.entrypoint.css index 5cbfb9756f..d49bec0196 100644 --- a/web/src/standalone/api-browser/index.entrypoint.css +++ b/web/src/standalone/api-browser/index.entrypoint.css @@ -9,6 +9,13 @@ img.logo { min-height: 48px; } +i.logo { + width: 100%; + padding: 1rem 0.5rem 1.5rem 0.5rem; + font-size: 3rem; + text-align: center; +} + [part="rapi-doc"] { height: 100%; } diff --git a/web/src/standalone/api-browser/index.entrypoint.ts b/web/src/standalone/api-browser/index.entrypoint.ts index d01ec46ac3..26fab2632e 100644 --- a/web/src/standalone/api-browser/index.entrypoint.ts +++ b/web/src/standalone/api-browser/index.entrypoint.ts @@ -10,7 +10,7 @@ import { getCookie } from "#common/utils"; import { Interface } from "#elements/Interface"; import { WithBrandConfig } from "#elements/mixins/branding"; -import { themeImage } from "#elements/utils/images"; +import { renderImage } from "#elements/utils/images"; import { msg } from "@lit/localize"; import { CSSResult, html, TemplateResult } from "lit"; @@ -98,11 +98,7 @@ export class APIBrowser extends WithBrandConfig(Interface) { }} >
    - + ${renderImage(this.brandingLogo, msg("authentik Logo"), "logo")}
    diff --git a/web/src/styles/authentik/components/Login/login.css b/web/src/styles/authentik/components/Login/login.css index 16f14dca06..f9cdcae766 100644 --- a/web/src/styles/authentik/components/Login/login.css +++ b/web/src/styles/authentik/components/Login/login.css @@ -188,6 +188,18 @@ width: clamp(75%, calc(var(--ak-login--MaxWidth) / 2), 90%); min-height: 4rem; } + + /* Ensure Font Awesome logos scale similarly to image logos */ + .branding-logo.font-awesome, + .branding-logo.fas, + .branding-logo.far, + .branding-logo.fab { + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(3rem, 8vw, 5rem); + line-height: 1; + } } .pf-c-login__main-body { diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts index cbc35e456d..0f2741b9ce 100644 --- a/web/src/user/LibraryApplication/index.ts +++ b/web/src/user/LibraryApplication/index.ts @@ -91,7 +91,7 @@ export const AKLibraryApp: LitFC = ({ exportparts="icon:card-header-icon" size=${PFSize.Large} name=${application.name} - icon=${ifPresent(application.metaIcon)} + icon=${ifPresent(application.metaIconUrl)} > ${rac ? html`
    ${this.renderAdminInterfaceLink()} diff --git a/website/docs/customize/branding.md b/website/docs/customize/branding.md index ccf0cad4f1..9ee3b8c9ef 100644 --- a/website/docs/customize/branding.md +++ b/website/docs/customize/branding.md @@ -5,6 +5,16 @@ slug: /branding You can configure several differently "branded" options depending on the associated domain, even though objects such as applications, providers, etc, are still global. This can be handy to use the same authentik instance, but branded differently for different domains. -The main settings that control your instance's appearance and behaviour are the [_branding settings_](../sys-mgmt/brands.md#branding-settings) and the the [_default flows_](../sys-mgmt/brands.md#default-flows). Review our tips for using images and icons in the [Image optimization](../sys-mgmt/brands.md#image-optimization) section. +The main settings that control your instance's appearance and behaviour are the **Branding settings** on your brand, and the the default flows that you specify. + +## Branding settings + +The [_branding settings_](../sys-mgmt/brands.md#branding-settings) control the title, logo, favicon that are displayed in your authentik instance. Here you can also select a specific image as your default flow background image, meaning it will display as the background for all flows. Note that you can can override this image on a per flow basis. You can also add custom CSS for the brand's pages. + +Review our tips for using images and icons in the [Image optimization](../sys-mgmt/brands.md#image-optimization) section. + +## Default flows + +As another way to customize your authentik instance, you can specify the [_default flows_](../sys-mgmt/brands.md#default-flows) that you want authentik to use. To create or modify a brand, open the Admin interface and navigate to **System** > **Brands**. For complete instructions refer to our [Brands documentation](../sys-mgmt/brands.md). diff --git a/website/docs/customize/files.md b/website/docs/customize/files.md new file mode 100644 index 0000000000..86833da262 --- /dev/null +++ b/website/docs/customize/files.md @@ -0,0 +1,20 @@ +--- +title: Files +--- + +Image files are used in authentik to add an icon to new applications that you add, or to a new source, and for defining the ["branded" look](../sys-mgmt/brands.md#branding-settings) of the authentik interface, with your company's logo and title, a favicon, or a background image for the flows. + +authentik provides a central place for storing all such files, the `authentik/data/media/public` directory. By default files are stored on disk, but [S3 storage](../sys-mgmt/ops/storage-s3.md) can also be configured. + +## Upload and manage files + +To upload and use images files, follow these steps: + +1. Log in to authentik as an administrator and open the authentik Admin interface. +2. Navigate to **Customization** > **Files**. + + Here you can upload a new file, delete a file, and search for a file that you already uploaded. + +:::info Using image URLs +Instead of uploading an image file to be used as an application's icon or source's icon, you can instead modify the specific object (application or source) and enter the URL for the image you want to use for that object. +::: diff --git a/website/docs/install-config/configuration/configuration.mdx b/website/docs/install-config/configuration/configuration.mdx index 258f50d11e..3078610d71 100644 --- a/website/docs/install-config/configuration/configuration.mdx +++ b/website/docs/install-config/configuration/configuration.mdx @@ -301,21 +301,151 @@ Requests directly coming from one an address within a CIDR specified here are ab Defaults to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fe80::/10`, `::1/128`. -## Media Storage Settings +## Storage settings + +These settings affect where files are stored. By default, they are stored on disk in the `/data` directory of the authentik container. S3 storage is also supported. + +#### `AUTHENTIK_STORAGE__BACKEND` + +This parameter defines where to store files. Valid values are `file` and `s3`. For `file` storage, files are stored in a `/data` directory in the container. For `s3`, see below. + +Defaults to `file`. + +### File storage backend settings + +#### `AUTHENTIK_STORAGE__FILE__PATH` + +Where to store files on disk. + +Defaults to `/data`. + +#### `AUTHENTIK_STORAGE__FILE__URL_EXPIRY` + +How long generated URLs for file access are valid for. + +Defaults to `minutes=15`. + +### S3 storage backend settings + +#### `AUTHENTIK_STORAGE__S3__REGION` + +S3 region where the bucket has been created. May be omitted depending on which S3 provider you use. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__ENDPOINT` + +Endpoint to use to talk to the S3 storage provider. Overrides the previous region and use_ssl settings. + +Must be a valid URL in the form of `https://s3.provider`. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__USE_SSL` + +Whether to use HTTPS when talking to the S3 storage providers. + +Defaults to `true`. + +#### `AUTHENTIK_STORAGE__S3__ADDRESSING_STYLE` + +Configure the addressing style used to address a bucket. + +Valid values are `auto` and `path`. + +Defaults to `auto`. + +#### `AUTHENTIK_STORAGE__S3__SESSION_PROFILE` + +Profile to use when using AWS SDK authentication. + +Supports hot-reloading. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__ACCESS_KEY` + +Access key to authenticate to S3. May be omitted if using AWS SDK authentication. + +Supports hot-reloading. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__SECRET_KEY` + +Secret key to authenticate to S3. May be omitted if using AWS SDK authentication. + +Supports hot-reloading. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__SECURITY_TOKEN` + +Security token to authenticate to S3. May be omitted. + +Supports hot-reloading. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__BUCKET_NAME` + +Name of the bucket to use to store files. + +#### `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` + +Domain to use to create URLs for users. Mainly useful for non-AWS providers. + +May include a port. Must include the bucket. + +Example: `s3.company:8080/authentik-data`. + +Defaults to not set. + +#### `AUTHENTIK_STORAGE__S3__SECURE_URLS` + +Whether URLs created use HTTPS or HTTP. + +Defaults to `true`. + +#### `AUTHENTIK_STORAGE__S3__URL_EXPIRY` + +How long generated URLs for file access are valid for. + +Defaults to `minutes=15`. + +### Media storage settings + +These settings affect where media files are stored. Those files include applications and sources icons. + +#### `AUTHENTIK_STORAGE__MEDIA__BACKEND` + +Overrides [`AUTHENTIK_STORAGE__BACKEND`](#authentik_storage__backend) + +#### `AUTHENTIK_STORAGE__MEDIA__FILE__[...]` + +Overrides [`AUTHENTIK_STORAGE__FILE__[...]`](#file-storage-backend-settings) settings. + +#### `AUTHENTIK_STORAGE__MEDIA__S3__[...]` + +Overrides [`AUTHENTIK_STORAGE__FILE__[...]`](#file-storage-backend-settings) settings. These settings affect where media files are stored. Those files include applications and sources icons. By default, they are stored on disk in the `/media` directory of the authentik container. S3 storage is also supported. -- `AUTHENTIK_STORAGE__MEDIA__BACKEND`: Where to store files. Valid values are `file` and `s3`. For `file` storage, files are stored in a `/media` directory in the container. For `s3`, see below. -- `AUTHENTIK_STORAGE__MEDIA__S3__REGION`: S3 region where the bucket has been created. May be omitted depending on which S3 provider you use. No default. -- `AUTHENTIK_STORAGE__MEDIA__S3__USE_SSL`: Whether to use HTTPS when talking to the S3 storage providers. Defaults to `true`. -- `AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT`: Endpoint to use to talk to the S3 storage provider. Override the previous region and use_ssl settings. Must be a valid URL in the form of `https://s3.provider`. No default. -- `AUTHENTIK_STORAGE__MEDIA__S3__SESSION_PROFILE`: Profile to use when using AWS SDK authentication. No default. Supports hot-reloading. -- `AUTHENTIK_STORAGE__MEDIA__S3__ACCESS_KEY`: Access key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading. -- `AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY`: Secret key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading. -- `AUTHENTIK_STORAGE__MEDIA__S3__SECURITY_TOKEN`: Security token to authenticate to S3. May be omitted. Supports hot-reloading. -- `AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME`: Name of the bucket to use to store files. -- `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN`: Domain to use to create URLs for users. Mainly useful for non-AWS providers. May include a port. Must include the bucket. Example: `s3.company:8080/authentik-media`. -- `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS`: Whether URLs created use HTTPS (set to `true` by default) or HTTP. +### Reports storage settings + +These settings affect where CSV reports are stored. + +#### `AUTHENTIK_STORAGE__REPORTS__BACKEND` + +Overrides [`AUTHENTIK_STORAGE__BACKEND`](#authentik_storage__backend) + +#### `AUTHENTIK_STORAGE__REPORTS__FILE__[...]` + +Overrides [`AUTHENTIK_STORAGE__FILE__[...]`](#file-storage-backend-settings) settings. + +#### `AUTHENTIK_STORAGE__REPORTS__S3__[...]` + +Overrides [`AUTHENTIK_STORAGE__S3__[...]`](#s3-storage-backend-settings) settings. ## authentik Settings diff --git a/website/docs/releases/2025/v2025.12.md b/website/docs/releases/2025/v2025.12.md new file mode 100644 index 0000000000..4cf442ae31 --- /dev/null +++ b/website/docs/releases/2025/v2025.12.md @@ -0,0 +1,76 @@ +--- +title: Release 2025.12 +slug: "/releases/2025.12" +--- + +:::info +2025.12 has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates. + +To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as xxxx.x.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet. +::: + +## Highlights + +## Breaking changes + +### Storage improvements + +Files stored by authentik are now served from the `/files` prefix, and not from `/media` anymore. + +#### Storage mount changes + +If local storage is used, authentik now expects a mount at `/data` for file storage. The existing `/media` mount must be moved to `/data/media`. + +For Docker Compose users, the migration is as follows: + +```shell +# Shut down authentik +docker compose down +# Create the new storage folder +mkdir -p ./data +# Move the old media storage to the new location +mv ./media ./data/media +# Download the new Docker Compose with the updated paths and start authentik. See below for details. +``` + +#### Storage configuration changes + +New storage configuration options are available. See the [storage settings reference](../../install-config/configuration/configuration.mdx#storage-settings) for details. + +## New features and improvements + +## Upgrading + +This release does not introduce any new requirements. You can follow the upgrade instructions below; for more detailed information about upgrading authentik, refer to our [Upgrade documentation](../../install-config/upgrade.mdx). + +:::warning +When you upgrade, be aware that the version of the authentik instance and of any outposts must be the same. We recommended that you always upgrade any outposts at the same time you upgrade your authentik instance. +::: + +### Docker Compose + +To upgrade, download the new docker-compose file and update the Docker stack with the new version, using these commands: + +```shell +wget -O docker-compose.yml https://goauthentik.io/version/2025.12/docker-compose.yml +docker compose up -d +``` + +The `-O` flag retains the downloaded file's name, overwriting any existing local file with the same name. + +### Kubernetes + +Upgrade the Helm Chart to the new version, using the following commands: + +```shell +helm repo update +helm upgrade authentik authentik/authentik -f values.yaml --version ^xxxx.x +``` + +## Minor changes/fixes + + + +## API Changes + + diff --git a/website/docs/sidebar.mjs b/website/docs/sidebar.mjs index 2433a32943..f1c1f139eb 100644 --- a/website/docs/sidebar.mjs +++ b/website/docs/sidebar.mjs @@ -451,6 +451,7 @@ const items = [ ], }, "customize/branding", + "customize/files", ], }, { diff --git a/website/docs/sys-mgmt/brands.md b/website/docs/sys-mgmt/brands.md index 0ba2b5abe9..e881ddcaed 100644 --- a/website/docs/sys-mgmt/brands.md +++ b/website/docs/sys-mgmt/brands.md @@ -21,11 +21,11 @@ To create or edit a brand, follow these steps: The brand settings define the visual identity of the brand, including: -- **Branding title**: Displayed in the browser tab (document title) and throughout the UI; -- **Logo**: Appears in the sidebar/header; +- **Branding title**: Displayed in the browser tab (document title) and throughout the UI. +- **Logo**: Displayed in the upper-left corner. :::info - Starting with authentik 2024.6.2, the placeholder `%(theme)s` can be used in the logo configuration option, which will be replaced with the active theme. + The placeholder `%(theme)s` can be used in the logo configuration option, which will be replaced with the active theme. ::: - **Favicon**: Shown on the browser tab. diff --git a/website/docs/sys-mgmt/ops/storage-s3.md b/website/docs/sys-mgmt/ops/storage-s3.md index ccc9a2791e..ed314359aa 100644 --- a/website/docs/sys-mgmt/ops/storage-s3.md +++ b/website/docs/sys-mgmt/ops/storage-s3.md @@ -8,7 +8,7 @@ First, create a user on your S3 storage provider and get access credentials (her You will also need the S3 API endpoint that authentik will use (hereafter referred to as `https://s3.provider`). When using AWS S3, there’s no need to set the endpoint, but for S3-compatible services like Azure Blob Storage or Cloudflare R2, use the provider's endpoint URL. -Create or pick a bucket for authentik media, for example `authentik-media`. Adjust the name to your provider’s bucket naming rules. We suffix with `-media` as authentik currently only stores media files (icons, etc.). +Create or pick a bucket for authentik data, for example `authentik-data`. Adjust the name to your provider’s bucket naming rules. The domain you use to access authentik is referred to as `authentik.company` in the examples below. @@ -21,7 +21,7 @@ You will also need the AWS CLI available locally. Create the bucket that authentik will use for media files: ```bash -AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider create-bucket --bucket=authentik-media --acl=private +AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider create-bucket --bucket=authentik-data --acl=private ``` If using AWS S3, you can omit `--endpoint-url`, but you may need to specify `--region`. Some regions require `--create-bucket-configuration LocationConstraint=`. @@ -52,7 +52,7 @@ If authentik is accessed from multiple domains, include each one in `AllowedOrig Apply the policy to the bucket: ```bash -AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider put-bucket-cors --bucket=authentik-media --cors-configuration=file://cors.json +AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider put-bucket-cors --bucket=authentik-data --cors-configuration=file://cors.json ``` ### Configuring authentik @@ -60,57 +60,57 @@ AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoi Add the following to your `.env` file: ```env -AUTHENTIK_STORAGE__MEDIA__BACKEND=s3 -AUTHENTIK_STORAGE__MEDIA__S3__ACCESS_KEY=access_key -AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY=secret_key -AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME=authentik-media +AUTHENTIK_STORAGE__BACKEND=s3 +AUTHENTIK_STORAGE__S3__ACCESS_KEY=access_key +AUTHENTIK_STORAGE__S3__SECRET_KEY=secret_key +AUTHENTIK_STORAGE__S3__BUCKET_NAME=authentik-data ``` If you are using AWS S3, add: ```env -AUTHENTIK_STORAGE__MEDIA__S3__REGION=us-east-1 # Use the region of the bucket +AUTHENTIK_STORAGE__S3__REGION=us-east-1 # Use the region of the bucket ``` If you are using an S3‑compatible provider (non‑AWS), add: ```env -AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT=https://s3.provider -AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN=s3.provider/authentik-media +AUTHENTIK_STORAGE__S3__ENDPOINT=https://s3.provider +AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=s3.provider/authentik-media ``` -The `AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT` setting controls how authentik communicates with the S3 provider. When set, it overrides region/`USE_SSL`. +The `AUTHENTIK_STORAGE__S3__ENDPOINT` setting controls how authentik communicates with the S3 provider. When set, it overrides region/`USE_SSL`. -The `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN` setting controls how media URLs are built for the web interface. It must include the bucket name and must not include a scheme. +The `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` setting controls how media URLs are built for the web interface. It must include the bucket name and must not include a scheme. -For a path-style domain, set `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN=s3.provider/authentik-media`. The object `application-icons/application.png` will be available at `https://s3.provider/authentik-media/application-icons/application.png`. +For a path-style domain, set `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=s3.provider/authentik-media`. The object `application-icons/application.png` will be available at `https://s3.provider/authentik-media/application-icons/application.png`. -Whether URLs use HTTPS is controlled by `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS` (defaults to `true`). Depending on your provider, you can also use a virtual hosted-style domain such as `authentik-media.s3.provider`. +Whether URLs use HTTPS is controlled by `AUTHENTIK_STORAGE__S3__SECURE_URLS` (defaults to `true`). Depending on your provider, you can also use a virtual hosted-style domain such as `authentik-data.s3.provider`. :::info -You can omit `ACCESS_KEY` and `SECRET_KEY` when using AWS SDK authentication (instance roles or profiles). See `AUTHENTIK_STORAGE__MEDIA__S3__SESSION_PROFILE` and related options in the configuration reference](../../install-config/configuration/configuration.mdx#media-storage-settings). +You can omit `ACCESS_KEY` and `SECRET_KEY` when using AWS SDK authentication (instance roles or profiles). See `AUTHENTIK_STORAGE__S3__SESSION_PROFILE` and related options in the configuration reference](../../install-config/configuration/configuration.mdx#storage-settings). ::: -For more options (including `AUTHENTIK_STORAGE__MEDIA__S3__USE_SSL`, session profiles, and security tokens), see the [configuration reference](../../install-config/configuration/configuration.mdx#media-storage-settings). +For more options (including `AUTHENTIK_STORAGE__S3__USE_SSL`, session profiles, and security tokens), see the [configuration reference](../../install-config/configuration/configuration.mdx#storage-settings). ## Migrating between storage backends -The following assumes the local storage path is `/media` and the bucket is `authentik-media`. Ensure your `aws` CLI is configured to talk to your provider (add `--endpoint-url` or `--region` as needed). +The following assumes the local storage path is `/data` and the bucket is `authentik-data`. Ensure your `aws` CLI is configured to talk to your provider (add `--endpoint-url` or `--region` as needed). ### From file to s3 Follow the setup steps above, then sync files from the local directory to S3 (to the bucket root): ```bash -aws s3 sync /media s3://authentik-media/ +aws s3 sync /data s3://authentik-data/ # For non-AWS providers, include the endpoint: -# aws --endpoint-url=https://s3.provider s3 sync /media s3://authentik-media/ +# aws --endpoint-url=https://s3.provider s3 sync /data s3://authentik-data/ ``` ### From s3 to file ```bash -aws s3 sync s3://authentik-media/ /media +aws s3 sync s3://authentik-data/ /data # For non-AWS providers: -# aws --endpoint-url=https://s3.provider s3 sync s3://authentik-media/ /media +# aws --endpoint-url=https://s3.provider s3 sync s3://authentik-data/ /data ```