Compare commits

..

2 Commits

Author SHA1 Message Date
Teffen Ellis
d1d1d1597f Remove stub. 2025-12-08 19:48:57 +01:00
Teffen Ellis
c9427cd6c1 web: Remove PFBase. 2025-12-08 19:48:57 +01:00
460 changed files with 8825 additions and 146776 deletions

View File

@@ -1,63 +0,0 @@
# syntax=docker/dockerfile:1
# Start from the same FIPS Python base as production (python-base stage)
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff
USER root
# Setup environment matching production python-base stage
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_NATIVE_TLS=1 \
UV_PYTHON_DOWNLOADS=0
WORKDIR /ak-root
# Copy uv package manager
COPY --from=ghcr.io/astral-sh/uv:0.9.7@sha256:ba4857bf2a068e9bc0e64eed8563b065908a4cd6bfb66b531a9c424c8e25e142 /uv /uvx /bin/
# Install build dependencies
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \
apt-get update && \
apt-get install -y --no-install-recommends \
# Build essentials
build-essential pkg-config libffi-dev git binutils \
# cryptography
curl \
# libxml
libxslt-dev zlib1g-dev \
# postgresql
libpq-dev \
# python-kadmin-rs and kerberos testing
clang libkrb5-dev sccache krb5-kdc krb5-admin-server \
# xmlsec
libltdl-dev \
# runit (for chpst command used by lifecycle/ak)
runit \
# sudo (required by devcontainer features)
sudo && \
rm -rf /var/lib/apt/lists/*
# Environment for building native Python packages
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
RUSTUP_PERMIT_COPY_RENAME="true"
# Create authentik user with proper home directory (required for devcontainer features)
RUN adduser --disabled-password --gecos "" --uid 1000 --home /home/authentik authentik && \
mkdir -p /certs /media /ak-root && \
chown -R authentik:authentik /certs /media /ak-root /home/authentik && \
echo "authentik ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/authentik
# FIPS configuration for Go development
# Don't set GOFIPS/GOFIPS140 globally to avoid breaking Go tools like docker-compose
# These will be set when building/running authentik Go code (see lifecycle/ak and Makefile)
ENV CGO_ENABLED=1
# Set TMPDIR for PID files and temp data
# Use /tmp instead of /dev/shm for development because go run needs to execute binaries
ENV TMPDIR=/tmp
USER authentik

View File

@@ -1,68 +0,0 @@
{
"name": "authentik",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/ak-root",
"containerUser": "authentik",
"remoteUser": "authentik",
"shutdownAction": "stopCompose",
"containerEnv": {
"LOCAL_PROJECT_DIR": "/ak-root"
},
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.24"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": false
}
},
"mounts": [],
"forwardPorts": [9000, 9443],
"portsAttributes": {
"8000": {
"onAutoForward": "ignore"
},
"3963": {
"onAutoForward": "ignore"
},
"35151": {
"onAutoForward": "ignore"
},
"9901": {
"onAutoForward": "ignore"
}
},
"postCreateCommand": "bash .devcontainer/setup.sh",
"customizations": {
"vscode": {
"extensions": [
"EditorConfig.EditorConfig",
"bashmish.es6-string-css",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"golang.go",
"Gruntfuggly.todo-tree",
"ms-python.black-formatter",
"ms-python.isort",
"ms-python.pylint",
"ms-python.python",
"ms-python.vscode-pylance",
"redhat.vscode-yaml",
"Tobermory.es6-string-html",
"charliermarsh.ruff"
],
"settings": {
"python.defaultInterpreterPath": "/ak-root/.venv/bin/python",
"python.terminal.activateEnvironment": true
}
}
}
}

View File

@@ -1,50 +0,0 @@
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
user: authentik
privileged: true
volumes:
- ../:/ak-root
entrypoint: []
command: sleep infinity
depends_on:
postgresql:
condition: service_healthy
env_file: .env
environment:
PATH: "/ak-root/.venv/bin:${PATH}"
ports:
- "9000:9000"
- "9443:9443"
postgresql:
image: docker.io/library/postgres:16
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d authentik -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- postgres-data:/var/lib/postgresql/data
env_file: .env
command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"]
s3:
image: docker.io/zenko/cloudserver
env_file: .env
environment:
REMOTE_MANAGEMENT_DISABLE: "1"
ports:
- "8020:8000"
volumes:
- s3-data:/usr/src/app/localData
- s3-metadata:/usr/src/app/localMetadata
volumes:
postgres-data:
s3-data:
s3-metadata:

View File

@@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -e
echo "======================================"
echo "Running authentik devcontainer setup"
echo "======================================"
echo ""
echo "Step 1/5: Installing dependencies"
make install
echo ""
echo "Step 2/5: Generating development config"
make gen-dev-config
echo ""
echo "Step 3/5: Running database migrations"
make migrate
echo ""
echo "Step 4/5: Generating API clients"
make gen
echo ""
echo "Step 5/5: Building web assets"
make web
echo ""
echo "======================================"
echo "Setup complete!"
echo "======================================"
echo ""
echo "You can now run:"
echo " - 'make run-server' to start the backend server"
echo " - 'make run-worker' to start the worker (must be ran once after initial setup)"
echo " - 'make web-watch' for live web development"
echo ""

View File

@@ -8,7 +8,7 @@ inputs:
runs:
using: "composite"
steps:
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
flags: ${{ inputs.flags }}
use_oidc: true

View File

@@ -2,10 +2,6 @@
👋 Hi there! Welcome.
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
⚠️ IMPORTANT: Make sure you are opening this PR from a FEATURE BRANCH, not from your main branch!
If you opened this PR from your main branch, please close it and create a new feature branch instead.
For more information, see: https://docs.goauthentik.io/developer-docs/contributing/#always-use-feature-branches
-->
## Details

View File

@@ -73,12 +73,14 @@ jobs:
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Setup node
if: ${{ !inputs.release }}
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: generate ts client
if: ${{ !inputs.release }}
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6

View File

@@ -46,7 +46,7 @@ jobs:
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -41,7 +41,7 @@ jobs:
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
${{ github.workspace }}/website/api/.docusaurus

View File

@@ -201,7 +201,7 @@ jobs:
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b

View File

@@ -42,7 +42,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- run: uv run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -40,7 +40,7 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json

View File

@@ -73,7 +73,7 @@ jobs:
- name: Bump version
run: "make bump version=${{ inputs.next_version }}.0-rc1"
- name: Create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: release-bump-${{ inputs.next_version }}

View File

@@ -130,7 +130,7 @@ jobs:
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
./scripts/helm-docs.sh
- name: Create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -185,7 +185,7 @@ jobs:
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
mv version.new.json version.json
- name: Create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}

View File

@@ -44,7 +44,7 @@ jobs:
make web-check-compile
- name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

@@ -28,10 +28,8 @@ packages/django-channels-postgres @goauthentik/backend
packages/django-postgres-cache @goauthentik/backend
packages/django-dramatiq-postgres @goauthentik/backend
# Web packages
package.json @goauthentik/frontend
package-lock.json @goauthentik/frontend
packages/package.json @goauthentik/frontend
packages/package-lock.json @goauthentik/frontend
packages/package.json @goauthentik/backend @goauthentik/frontend
packages/package-lock.json @goauthentik/backend @goauthentik/frontend
packages/docusaurus-config @goauthentik/frontend
packages/esbuild-plugin-live-reload @goauthentik/frontend
packages/eslint-config @goauthentik/frontend

View File

@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:4f9d98ebaa759f776496d850e0439c48948d587b191fc3949b5f5e4667abef90 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
FROM ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base

View File

@@ -46,7 +46,7 @@ help: ## Show this help
@echo ""
go-test:
GOFIPS140=latest CGO_ENABLED=1 go test -timeout 0 -v -race -cover ./...
go test -timeout 0 -v -race -cover ./...
test: ## Run the server tests and produce a coverage report (locally)
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2026.2.0-rc1"
VERSION = "2025.12.0-rc1"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -1,9 +1,4 @@
from pathlib import Path
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.config import CONFIG
class AuthentikFilesConfig(ManagedAppConfig):
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
label = "authentik_admin_files"
verbose_name = "authentik Files"
default = True
@ManagedAppConfig.reconcile_global
def check_for_media_mount(self):
if settings.TEST:
return
from authentik.events.models import Event, EventAction
if (
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
and Path("/media").exists()
):
Event.new(
EventAction.CONFIGURATION_ERROR,
message="/media has been moved to /data/media. "
"Check the release notes for migration steps.",
).save()

View File

@@ -1,13 +1,10 @@
from collections.abc import Callable, Generator, Iterator
from typing import cast
from collections.abc import Generator, Iterator
from django.core.cache import cache
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.admin.files.usage import FileUsage
CACHE_PREFIX = "goauthentik.io/admin/files"
LOGGER = get_logger()
@@ -56,19 +53,13 @@ class Backend:
"""
raise NotImplementedError
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
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
use_cache: whether to retrieve the URL from cache
Returns:
URL to access the file (may be relative or absolute depending on backend)
@@ -141,22 +132,3 @@ class ManageableBackend(Backend):
True if file exists, False otherwise
"""
raise NotImplementedError
def _cache_get_or_set(
self,
name: str,
request: HttpRequest | None,
default: Callable[[str, HttpRequest | None], str],
timeout: int,
) -> str:
timeout_ignore = 60
timeout = int(timeout * 0.67)
if timeout < timeout_ignore:
timeout = 0
request_key = "None"
if request is not None:
request_key = f"{request.build_absolute_uri('/')}"
cache_key = f"{CACHE_PREFIX}/{self.name}/{self.usage}/{request_key}/{name}"
return cast(str, cache.get_or_set(cache_key, lambda: default(name, request), timeout))

View File

@@ -63,12 +63,7 @@ class FileBackend(ManageableBackend):
rel_path = full_path.relative_to(self.base_path)
yield str(rel_path)
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Get URL for accessing the file."""
expires_in = timedelta_from_string(
CONFIG.get(
@@ -77,28 +72,21 @@ class FileBackend(ManageableBackend):
)
)
def _file_url(name: str, request: HttpRequest | None) -> str:
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)
if use_cache:
timeout = int(expires_in.total_seconds())
return self._cache_get_or_set(name, request, _file_url, timeout)
else:
return _file_url(name, request)
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."""

View File

@@ -38,11 +38,6 @@ class PassthroughBackend(Backend):
"""External files cannot be listed."""
yield from []
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Return the URL as-is for passthrough files."""
return name

View File

@@ -130,57 +130,44 @@ class S3Backend(ManageableBackend):
if rel_path: # Skip if it's just the directory itself
yield rel_path
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
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),
)
expires_in = int(
timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
).total_seconds()
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"),
)
)
def _file_url(name: str, request: HttpRequest | None) -> str:
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in.total_seconds(),
HttpMethod="GET",
)
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in,
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}"
# 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
if use_cache:
return self._cache_get_or_set(name, request, _file_url, expires_in)
else:
return _file_url(name, request)
return url
def save_file(self, name: str, content: bytes) -> None:
"""Save file to S3."""

View File

@@ -44,12 +44,7 @@ class StaticBackend(Backend):
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,
use_cache: bool = True,
) -> str:
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}"

View File

@@ -70,7 +70,6 @@ class FileManager:
self,
name: str | None,
request: HttpRequest | Request | None = None,
use_cache: bool = True,
) -> str:
"""
Get URL for accessing the file.

View File

@@ -13,11 +13,6 @@ class Pagination(pagination.PageNumberPagination):
page_query_param = "page"
page_size_query_param = "page_size"
def get_page_size(self, request):
if self.page_size_query_param in request.query_params:
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
return request.tenant.pagination_default_page_size
def get_paginated_response(self, data):
previous_page_number = 0
if self.page.has_previous():

View File

@@ -1,12 +1,10 @@
"""authentik Blueprints app"""
import traceback
from collections.abc import Callable
from importlib import import_module
from inspect import ismethod
from django.apps import AppConfig
from django.conf import settings
from django.db import DatabaseError, InternalError, ProgrammingError
from dramatiq.broker import get_broker
from structlog.stdlib import BoundLogger, get_logger
@@ -46,21 +44,8 @@ class ManagedAppConfig(AppConfig):
module_name = f"{self.name}.{rel_module}"
import_module(module_name)
self.logger.info("Imported related module", module=module_name)
except ModuleNotFoundError as exc:
if settings.DEBUG:
# This is a heuristic for determining whether the exception was caused
# "directly" by the `import_module` call or whether the initial import
# succeeded and a later import (within the existing module) failed.
# 1. <the calling function>
# 2. importlib.import_module
# 3. importlib._bootstrap._gcd_import
# 4. importlib._bootstrap._find_and_load
# 5. importlib._bootstrap._find_and_load_unlocked
STACK_LENGTH_HEURISTIC = 5
stack_length = len(traceback.extract_tb(exc.__traceback__))
if stack_length > STACK_LENGTH_HEURISTIC:
raise
except ModuleNotFoundError:
pass
import_relative("checks")
import_relative("tasks")

View File

@@ -5,7 +5,6 @@ from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from django.db.models.fields.related import OneToOneField
from drf_jsonschema_serializer.convert import converter, field_to_converter
from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.relations import PrimaryKeyRelatedField
@@ -33,8 +32,6 @@ class PrimaryKeyRelatedFieldConverter:
def convert(self, field: PrimaryKeyRelatedField):
model: Model = field.queryset.model
pk_field = model._meta.pk
if isinstance(pk_field, OneToOneField):
pk_field = pk_field.related_fields[0][1]
if isinstance(pk_field, fields.UUIDField):
return {"type": "string", "format": "uuid"}
return {"type": "integer"}

View File

@@ -4,8 +4,7 @@ from collections.abc import Iterator
from copy import copy
from django.core.cache import cache
from django.db.models import Case, QuerySet
from django.db.models.expressions import When
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
@@ -23,7 +22,6 @@ from authentik.api.pagination import Pagination
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
@@ -57,21 +55,9 @@ class ApplicationSerializer(ModelSerializer):
def get_launch_url(self, app: Application) -> str | None:
"""Allow formatting of launch URL"""
user = None
user_data = None
if "request" in self.context:
user = self.context["request"].user
# Cache serialized user data to avoid N+1 when formatting launch URLs
# for multiple applications. UserSerializer accesses user.ak_groups which
# would otherwise trigger a query for each application.
if user is not None:
if "_cached_user_data" not in self.context:
# Prefetch groups to avoid N+1
self.context["_cached_user_data"] = UserSerializer(instance=user).data
user_data = self.context["_cached_user_data"]
return app.get_launch_url(user, user_data=user_data)
return app.get_launch_url(user)
def validate_slug(self, slug: str) -> str:
if slug in Application.reserved_slugs:
@@ -164,26 +150,11 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
applications.append(application)
return applications
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
"""
Re-fetch with proper prefetching for serialization
Cached applications don't have prefetched relationships, causing N+1 queries
during serialization when get_provider() is called
"""
if not applications:
return self.get_queryset().none()
pks = [app.pk for app in applications]
return (
self.get_queryset()
.filter(pk__in=pks)
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
)
def _filter_applications_with_launch_url(
self, applications: QuerySet[Application]
self, paginated_apps: Iterator[Application]
) -> list[Application]:
applications = []
for app in applications:
for app in paginated_apps:
if app.get_launch_url():
applications.append(app)
return applications
@@ -283,8 +254,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
except ValueError as exc:
raise ValidationError from exc
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
allowed_applications = self._expand_applications(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)
@@ -303,7 +272,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
allowed_applications,
timeout=86400,
)
allowed_applications = self._expand_applications(allowed_applications)
if only_with_launch_url == "true":
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)

View File

@@ -86,7 +86,6 @@ from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.lib.utils.reflection import ConditionalInheritance
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import Role, get_permission_choices
@@ -485,11 +484,7 @@ class UsersFilter(FilterSet):
]
class UserViewSet(
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
UsedByMixin,
ModelViewSet,
):
class UserViewSet(UsedByMixin, ModelViewSet):
"""User Viewset"""
queryset = User.objects.none()

View File

@@ -152,7 +152,7 @@ class AttributesMixin(models.Model):
@classmethod
def update_or_create_attributes(
cls, query: dict[str, Any], properties: dict[str, Any]
) -> tuple[Self, bool]:
) -> tuple[models.Model, bool]:
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
instance = cls.objects.filter(**query).first()
if not instance:
@@ -658,10 +658,6 @@ class ApplicationQuerySet(QuerySet):
qs = self.select_related("provider")
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
qs = qs.select_related(f"provider__{subclass}")
# Also prefetch/select through each subclass path to ensure casted instances have access
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
qs = qs.select_related(f"provider__{subclass}__application")
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
return qs
@@ -713,15 +709,8 @@ class Application(SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
def get_launch_url(
self, user: Optional["User"] = None, user_data: dict | None = None
) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
Args:
user: User instance for formatting the URL
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
"""
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."""
from authentik.core.api.users import UserSerializer
url = None
@@ -731,10 +720,7 @@ class Application(SerializerModel, PolicyBindingModel):
url = provider.launch_url
if user and url:
try:
# Use pre-serialized data if available, otherwise serialize now
if user_data is None:
user_data = UserSerializer(instance=user).data
return url % user_data
return url % UserSerializer(instance=user).data
except Exception as exc: # noqa
LOGGER.warning("Failed to format launch url", exc=exc)
return url

View File

@@ -34,12 +34,18 @@ class SessionStore(SessionBase):
def _get_session_from_db(self):
try:
return self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
).get(
session_key=self.session_key,
expires__gt=timezone.now(),
return (
self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
)
.get(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
@@ -48,12 +54,18 @@ class SessionStore(SessionBase):
async def _aget_session_from_db(self):
try:
return await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
).aget(
session_key=self.session_key,
expires__gt=timezone.now(),
return (
await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
)
.aget(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):

View File

@@ -39,7 +39,7 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
return
model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]()
else:
model_class = test_model()
model_class.slug = "test"

View File

@@ -175,7 +175,7 @@ class Connector(ScheduledModel, SerializerModel):
]
class DeviceAccessGroup(SerializerModel, PolicyBindingModel):
class DeviceAccessGroup(PolicyBindingModel):
name = models.TextField(unique=True)

View File

@@ -1,129 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import QuerySet
from django.urls import reverse
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import User
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.reports.models import DataExport
from authentik.enterprise.reports.tasks import generate_export
from authentik.rbac.permissions import HasPermission
class RequestedBySerializer(ModelSerializer):
class Meta:
model = User
fields = ("pk", "username")
class ContentTypeSerializer(ModelSerializer):
app_label = CharField(read_only=True)
model = CharField(read_only=True)
class Meta:
model = ContentType
fields = ("id", "app_label", "model")
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
requested_by = RequestedBySerializer(read_only=True)
content_type = ContentTypeSerializer(read_only=True)
class Meta:
model = DataExport
fields = (
"id",
"requested_by",
"requested_on",
"content_type",
"query_params",
"file_url",
"completed",
)
read_only_fields = (
"id",
"requested_by",
"requested_on",
"content_type",
"file_url",
"completed",
)
class DataExportViewSet(
mixins.RetrieveModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, GenericViewSet
):
queryset = DataExport.objects.all()
serializer_class = DataExportSerializer
owner_field = "requested_by"
ordering_fields = ["completed", "requested_by", "requested_on", "content_type__model"]
ordering = ["-requested_on"]
search_fields = ["requested_by__username", "content_type__model"]
def get_queryset(self) -> QuerySet[DataExport]:
"""Limit to exports of content types the user has view permission on"""
qs = super().get_queryset()
permitted_cts = []
for ct in ContentType.objects.filter(
id__in=qs.values_list("content_type_id", flat=True).distinct()
):
model = ct.model_class()
if model is None:
continue
perm = f"{ct.app_label}.view_{ct.model}"
if self.request.user.has_perm(perm):
permitted_cts.append(ct)
return qs.filter(content_type__in=permitted_cts)
class ExportMixin:
@extend_schema(
request=None,
parameters=[],
responses={201: DataExportSerializer},
filters=True,
)
@action(
detail=False,
methods=["POST"],
permission_classes=[HasPermission("authentik_reports.add_dataexport")],
)
def export(self: GenericViewSet, request: Request) -> Response:
"""
Create a data export for this data type. Note that the export is generated asynchronously:
this method returns a `DataExport` object that will initially have `completed=false` as well
as the permanent URL to that object in the `Location` header.
You can poll that URL until `completed=true`, at which point the `file_url` property will
contain a URL to download
"""
s = DataExportSerializer(data={"query_params": request.query_params.dict()})
s.is_valid(raise_exception=True)
export = s.save(
requested_by=request.user,
content_type=ContentType.objects.get_for_model(self.queryset.model),
)
generate_export.send(export.id)
set = export.serializer(instance=export)
return Response(
set.data,
status=201,
headers={"Location": reverse("authentik_api:dataexport-detail", args=[export.id])},
)
def get_permissions(self: GenericViewSet) -> list[BasePermission]:
perms = super().get_permissions()
if self.action == "export":
model = self.get_queryset().model
perms.append(HasPermission(f"{model._meta.app_label}.view_{model._meta.model_name}")())
return perms

View File

@@ -1,8 +0,0 @@
from authentik.enterprise.apps import EnterpriseConfig
class ReportsConfig(EnterpriseConfig):
name = "authentik.enterprise.reports"
label = "authentik_reports"
verbose_name = "authentik Enterprise.Reports"
default = True

View File

@@ -1,48 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-02 17:19
import authentik.admin.files.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="DataExport",
fields=[
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("requested_on", models.DateTimeField(auto_now_add=True)),
("query_params", models.JSONField()),
("file", authentik.admin.files.fields.FileField(blank=True)),
("completed", models.BooleanField(default=False)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
(
"requested_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Data Export",
"verbose_name_plural": "Data Exports",
},
),
]

View File

@@ -1,123 +0,0 @@
import csv
import io
from uuid import uuid4
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext as _
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ModelViewSet
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 User
from authentik.enterprise.reports.utils import MockRequest
from authentik.events.models import Event, EventAction, Notification, NotificationSeverity
from authentik.lib.models import SerializerModel
from authentik.lib.utils.db import chunked_queryset
from authentik.tenants.utils import get_current_tenant
class DataExport(SerializerModel):
id = models.UUIDField(primary_key=True, default=uuid4)
requested_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
requested_on = models.DateTimeField(auto_now_add=True)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
query_params = models.JSONField()
file = FileField(blank=True)
completed = models.BooleanField(default=False)
class Meta:
verbose_name = _("Data Export")
verbose_name_plural = _("Data Exports")
@property
def serializer(self) -> type[Serializer]:
"""Get serializer for this model"""
from authentik.enterprise.reports.api.reports import DataExportSerializer
return DataExportSerializer
def generate(self) -> None:
if self.completed:
raise AssertionError("Data export must only be generated once")
model_class = self.content_type.model_class()
model_verbose_name = model_class._meta.verbose_name
model_verbose_name_plural = model_class._meta.verbose_name_plural
queryset = chunked_queryset(self.get_queryset())
serializer = self.get_serializer_class()(
context={"request": self._get_request()}, instance=queryset, many=True
)
self.file = f"{model_verbose_name_plural.lower()}_{self.id}.csv"
with get_file_manager(FileUsage.REPORTS).save_file_stream(self.file) as f:
with io.TextIOWrapper(f, encoding="utf-8", newline="") as text:
writer = csv.writer(text)
fields = [field.label for field in serializer.child.fields.values()]
writer.writerow(fields)
for record in queryset:
data = serializer.child.to_representation(record).values()
writer.writerow(data)
self.completed = True
self.save()
message = _(f"{model_verbose_name} export generated successfully")
e = Event.new(
EventAction.EXPORT_READY,
message=message,
export=self,
).set_user(self.requested_by)
e.save()
Notification.objects.create(
event=e,
severity=NotificationSeverity.NOTICE,
body=message,
hyperlink=self.file_url,
hyperlink_label=_("Download"),
user=self.requested_by,
)
@property
def file_url(self) -> str:
return get_file_manager(FileUsage.REPORTS).file_url(self.file)
def _get_request(self) -> MockRequest:
return MockRequest(
user=self.requested_by, query_params=self.query_params, tenant=get_current_tenant()
)
def get_queryset(self) -> models.QuerySet:
request = self._get_request()
viewset = self.get_viewset()
viewset.request = request
queryset = viewset.get_queryset()
queryset = viewset.filter_queryset(queryset)
return queryset
def get_viewset(self) -> ModelViewSet:
from authentik.core.api.users import UserViewSet
from authentik.events.api.events import EventViewSet
model = (self.content_type.app_label, self.content_type.model)
if model == ("authentik_core", "user"):
return UserViewSet()
elif model == ("authentik_events", "event"):
return EventViewSet()
raise NotImplementedError(f"Unsupported data export type {self.content_type.model}")
def get_serializer_class(self) -> type[Serializer]:
from authentik.enterprise.reports.serializers import (
ExportEventSerializer,
ExportUserSerializer,
)
if self.content_type.model == "user":
return ExportUserSerializer
elif self.content_type.model == "event":
return ExportEventSerializer
return self.get_viewset().get_serializer_class()

View File

@@ -1,32 +0,0 @@
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.events.api.events import EventSerializer
class ExportUserSerializer(UserSerializer):
"""Serializer for exporting users"""
groups = SerializerMethodField(source="get_groups")
def get_groups(self, instance: User) -> str:
return ",".join([group.name for group in instance.ak_groups.all()])
class Meta(UserSerializer.Meta):
fields = [f for f in UserSerializer.Meta.fields if f != "groups_obj"] + ["groups"]
class ExportEventSerializer(EventSerializer):
"""Serializer for exporting events"""
user_pk = IntegerField(source="user.pk", read_only=True)
username = CharField(source="user.username", read_only=True)
email = CharField(source="user.email", read_only=True)
class Meta(EventSerializer.Meta):
fields = [f for f in EventSerializer.Meta.fields if f != "user"] + [
"user_pk",
"username",
"email",
]

View File

@@ -1,10 +0,0 @@
from django.utils.translation import gettext_lazy as _
from dramatiq import actor
from authentik.enterprise.reports.models import DataExport
@actor(description=_("Generate data export."))
def generate_export(export_id: int):
export = DataExport.objects.get(id=export_id)
export.generate()

View File

@@ -1,53 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.enterprise.reports.tests.utils import patch_license
from authentik.events.models import Event
@patch_license
class TestExportAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_create_user_export(self):
"""Test User export endpoint"""
response = self.client.post(
reverse("authentik_api:user-export"),
)
self.assertEqual(response.status_code, 201)
self.assertEqual(
response.headers["Location"],
reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}),
)
self.assertEqual(response.data["requested_by"]["pk"], self.user.pk)
self.assertEqual(response.data["completed"], False)
self.assertEqual(response.data["file_url"], "")
self.assertEqual(response.data["query_params"], {})
self.assertEqual(
response.data["content_type"]["id"],
ContentType.objects.get_for_model(User).id,
)
def test_create_event_export(self):
"""Test Event export endpoint"""
response = self.client.post(
reverse("authentik_api:event-export"),
)
self.assertEqual(response.status_code, 201)
self.assertEqual(
response.headers["Location"],
reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}),
)
self.assertEqual(response.data["requested_by"]["pk"], self.user.pk)
self.assertEqual(response.data["completed"], False)
self.assertEqual(response.data["file_url"], "")
self.assertEqual(response.data["query_params"], {})
self.assertEqual(
response.data["content_type"]["id"],
ContentType.objects.get_for_model(Event).id,
)

View File

@@ -1,29 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.test.testcases import TestCase
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.reports.models import DataExport
from authentik.enterprise.reports.tests.utils import patch_license
from authentik.events.models import Event, EventAction
@patch_license
class TestEventExport(TestCase):
def setUp(self) -> None:
self.user = create_test_user()
self.user.assign_perms_to_managed_role("authentik_events.view_event")
self.e1 = Event.new(EventAction.LOGIN, user=self.user)
self.e1.save()
self.e2 = Event.new(EventAction.LOGIN_FAILED, user=self.user)
self.e2.save()
def test_type_filter(self):
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(Event),
requested_by=self.user,
query_params={"actions": [EventAction.LOGIN]},
)
records = list(export.get_queryset())
self.assertEqual(len(records), 1)
self.assertEqual(records[0], self.e1)

View File

@@ -1,80 +0,0 @@
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.reports.tests.utils import patch_license
@patch_license
class TestExportPermissions(APITestCase):
def setUp(self) -> None:
self.user = create_test_user()
self.client.force_login(self.user)
def test_export_without_permission(self):
"""Test User export endpoint without permission"""
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 403)
def test_export_only_user_permission(self):
"""Test User export endpoint with only view_user permission"""
self.user.assign_perms_to_managed_role("authentik_core.view_user")
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 403)
def test_export_with_permission(self):
"""Test User export endpoint with view_user and add_dataexport permission"""
self.user.assign_perms_to_managed_role("authentik_core.view_user")
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 201)
def test_export_access(self):
"""Test that data export access is restricted to the user who created it"""
self.user.assign_perms_to_managed_role("authentik_core.view_user")
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 201)
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200)
other_user = create_test_user()
other_user.assign_perms_to_managed_role("authentik_core.view_user")
other_user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
self.client.logout()
self.client.force_login(other_user)
response = self.client.get(export_url)
self.assertEqual(response.status_code, 404)
def test_export_access_no_datatype_permission(self):
"""Test that data export access requires view permission on the data type"""
self.user.assign_perms_to_managed_role("authentik_core.view_user")
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
self.user.assign_perms_to_managed_role("authentik_reports.view_dataexport")
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 201)
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200)
self.user.remove_perms_from_managed_role("authentik_core.view_user")
response = self.client.get(export_url)
self.assertEqual(response.status_code, 404)
response = self.client.get(reverse("authentik_api:dataexport-list"))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["results"]), 0)
def test_export_access_owner(self):
self.user.assign_perms_to_managed_role("authentik_core.view_user")
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
response = self.client.post(reverse("authentik_api:user-export"))
self.assertEqual(response.status_code, 201)
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200)
self.user.remove_perms_from_managed_role("authentik_core.view_user")
response = self.client.get(export_url)
self.assertEqual(response.status_code, 404)

View File

@@ -1,48 +0,0 @@
from django.test.testcases import TestCase
from drf_spectacular.generators import SchemaGenerator
from authentik.enterprise.reports.tests.utils import patch_license
@patch_license
class TestSchemaMatch(TestCase):
def setUp(self) -> None:
generator = SchemaGenerator()
self.schema = generator.get_schema(request=None, public=True)
def _index_params_by_name(self, parameters):
result = {}
for p in parameters or []:
if p.get("in") != "query":
continue
schema = p.get("schema", {})
result[p["name"]] = {
"required": p.get("required", False),
"type": schema.get("type"),
"format": schema.get("format"),
"enum": tuple(schema.get("enum", [])),
}
return result
def _find_operation_by_operation_id(self, operation_id):
for path_item in self.schema.get("paths", {}).values():
for operation in path_item.values():
if isinstance(operation, dict) and operation.get("operationId") == operation_id:
return operation
raise AssertionError(f"operationId '{operation_id}' not found in schema")
def _get_op_params(self, operation_id):
operation = self._find_operation_by_operation_id(operation_id)
return self._index_params_by_name(operation.get("parameters", []))
def test_user_export_action_query_params_match_list(self):
list_params = self._get_op_params("core_users_list")
del list_params["include_groups"] # Not applicable for export
del list_params["include_roles"] # Not applicable for export
export_params = self._get_op_params("core_users_export_create")
self.assertDictEqual(list_params, export_params)
def test_event_export_action_query_params_match_list(self):
list_params = self._get_op_params("events_events_list")
export_params = self._get_op_params("events_events_export_create")
self.assertDictEqual(list_params, export_params)

View File

@@ -1,75 +0,0 @@
import csv
from django.contrib.contenttypes.models import ContentType
from django.test.testcases import TestCase
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.core.models import User
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.reports.models import DataExport
from authentik.enterprise.reports.tests.utils import patch_license
@patch_license
class TestUserExport(FileTestFileBackendMixin, TestCase):
def setUp(self) -> None:
super().setUp()
self.u1 = create_test_user(username="a")
self.u1.assign_perms_to_managed_role("authentik_core.view_user")
self.u2 = create_test_user(username="b", path="abcd")
self.u1.assign_perms_to_managed_role("authentik_core.view_user")
def _read_export(self, filename):
with open(f"{self.reports_backend_path}/reports/public/{filename}") as f:
reader = csv.DictReader(f)
return list(reader)
def test_generate_user_export(self):
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(User),
requested_by=self.u1,
query_params={"email": str(self.u1.email)},
)
export.generate()
self.assertEqual(export.completed, True)
data = self._read_export(export.file)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["Username"], self.u1.username)
def test_path_filter(self):
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(User),
requested_by=self.u1,
query_params={"path": str(self.u2.path)},
)
records = list(export.get_queryset())
self.assertEqual(len(records), 1)
self.assertEqual(records[0], self.u2)
def test_search_filter(self):
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(User),
requested_by=self.u1,
query_params={"search": f'username = "{self.u2.username}"'},
)
records = list(export.get_queryset())
self.assertEqual(len(records), 1)
self.assertEqual(records[0], self.u2)
def test_ordering(self):
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(User),
requested_by=self.u1,
query_params={"ordering": "-username"},
)
records = list(export.get_queryset())
self.assertGreaterEqual(records[0].username, records[-1].username)
export = DataExport.objects.create(
content_type=ContentType.objects.get_for_model(User),
requested_by=self.u1,
query_params={"ordering": "username"},
)
records = list(export.get_queryset())
self.assertLess(records[0].username, records[-1].username)

View File

@@ -1,6 +0,0 @@
from unittest.mock import MagicMock, patch
patch_license = patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
MagicMock(return_value=True),
)

View File

@@ -1,7 +0,0 @@
"""API URLs"""
from authentik.enterprise.reports.api.reports import DataExportViewSet
api_urlpatterns = [
("reports/exports", DataExportViewSet),
]

View File

@@ -1,11 +0,0 @@
from dataclasses import dataclass
from authentik.core.models import User
from authentik.tenants.models import Tenant
@dataclass
class MockRequest:
user: User
query_params: dict[str, str]
tenant: Tenant

View File

@@ -9,7 +9,6 @@ TENANT_APPS = [
"authentik.enterprise.providers.radius",
"authentik.enterprise.providers.scim",
"authentik.enterprise.providers.ssf",
"authentik.enterprise.reports",
"authentik.enterprise.search",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",

View File

@@ -21,7 +21,6 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.object_types import TypeCreateSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.events.models import Event, EventAction
from authentik.lib.utils.reflection import ConditionalInheritance
class EventVolumeSerializer(PassiveSerializer):
@@ -117,9 +116,7 @@ class EventsFilter(django_filters.FilterSet):
fields = ["action", "client_ip", "username"]
class EventViewSet(
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"), ModelViewSet
):
class EventViewSet(ModelViewSet):
"""Event Read-Only Viewset"""
queryset = Event.objects.all()

View File

@@ -29,8 +29,6 @@ class NotificationSerializer(ModelSerializer):
"pk",
"severity",
"body",
"hyperlink",
"hyperlink_label",
"created",
"event",
"seen",

View File

@@ -1,59 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-05 10:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0013_delete_systemtask"),
]
operations = [
migrations.AddField(
model_name="notification",
name="hyperlink",
field=models.TextField(blank=True, max_length=4096, null=True),
),
migrations.AddField(
model_name="notification",
name="hyperlink_label",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("export_ready", "Export Ready"),
("custom_", "Custom Prefix"),
]
),
),
]

View File

@@ -117,8 +117,6 @@ class EventAction(models.TextChoices):
UPDATE_AVAILABLE = "update_available"
EXPORT_READY = "export_ready"
CUSTOM_PREFIX = "custom_"
@@ -263,14 +261,6 @@ class Event(SerializerModel, ExpiringModel):
return self.context["message"]
return f"{self.action}: {self.context}"
@property
def hyperlink(self) -> str | None:
return self.context.get("hyperlink")
@property
def hyperlink_label(self) -> str | None:
return self.context.get("hyperlink_label")
def __str__(self) -> str:
return f"Event action={self.action} user={self.user} context={self.context}"
@@ -489,11 +479,6 @@ class NotificationTransport(TasksModel, SerializerModel):
context["key_value"]["event_user_username"] = notification.event.user.get(
"username", None
)
if notification.hyperlink:
context["link"] = {
"target": notification.hyperlink,
"label": notification.hyperlink_label,
}
if notification.event:
context["title"] += notification.event.action
for key, value in notification.event.context.items():
@@ -547,8 +532,6 @@ class Notification(SerializerModel):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
severity = models.TextField(choices=NotificationSeverity.choices)
body = models.TextField()
hyperlink = models.TextField(blank=True, null=True, max_length=4096)
hyperlink_label = models.TextField(blank=True, null=True)
created = models.DateTimeField(auto_now_add=True)
event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
seen = models.BooleanField(default=False)

View File

@@ -110,12 +110,7 @@ def notification_transport(transport_pk: int, event_pk: str, user_pk: int, trigg
if not trigger:
return
notification = Notification(
severity=trigger.severity,
body=event.summary,
event=event,
user=user,
hyperlink=event.hyperlink,
hyperlink_label=event.hyperlink_label,
severity=trigger.severity, body=event.summary, event=event, user=user
)
transport: NotificationTransport = NotificationTransport.objects.filter(pk=transport_pk).first()
if not transport:

View File

@@ -27,7 +27,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
"""Stage Serializer"""
component = SerializerMethodField()
flow_set = FlowSetSerializer(many=True, required=False, read_only=True)
flow_set = FlowSetSerializer(many=True, required=False)
def to_representation(self, instance: Stage):
if isinstance(instance, Stage) and instance.is_in_memory:

View File

@@ -56,7 +56,6 @@ class TestFlowInspector(APITestCase):
"layout": "stacked",
},
"flow_designation": "authentication",
"passkey_challenge": None,
"password_fields": False,
"primary_action": "Log in",
"sources": [],

View File

@@ -301,6 +301,8 @@ class ConfigLoader:
return {}
try:
b64decoded_str = base64.b64decode(config_value).decode("utf-8")
b64decoded_str = b64decoded_str.strip().lstrip("{").rstrip("}")
b64decoded_str = "{" + b64decoded_str + "}"
return json.loads(b64decoded_str)
except (JSONDecodeError, TypeError, ValueError) as exc:
self.log(

View File

@@ -63,7 +63,6 @@ class BaseEvaluator:
"ak_call_policy": self.expr_func_call_policy,
"ak_create_event": self.expr_event_create,
"ak_create_jwt": self.expr_create_jwt,
"ak_create_jwt_raw": self.expr_create_jwt_raw,
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
"ak_logger": get_logger(self._filename).bind(),
"ak_send_email": self.expr_send_email,
@@ -223,16 +222,6 @@ class BaseEvaluator:
access_token.save()
return access_token.token
def expr_create_jwt_raw(
self, provider: OAuth2Provider | str, validity: str = "seconds=60", **kwargs
) -> str:
"""Issue a JWT for a given provider with completely customized data"""
if not isinstance(provider, OAuth2Provider):
provider = OAuth2Provider.objects.get(name=provider)
kwargs["exp"] = int((now() + timedelta_from_string(validity)).timestamp())
kwargs["aud"] = provider.client_id
return provider.encode(kwargs)
def expr_send_email(
self,
address: str | list[str],

View File

@@ -1,25 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from authentik.core.models import Source
from authentik.tasks.schedules.models import ScheduledModel
class SyncOutgoingTriggerMode(models.TextChoices):
# Do not trigger outgoing syncs
NONE = "none"
# Trigger immediately after object changed
IMMEDIATE = "immediate"
# Trigger at the end of full sync
DEFERRED_END = "deferred_end"
class IncomingSyncSource(ScheduledModel, Source):
sync_outgoing_trigger_mode = models.TextField(
choices=SyncOutgoingTriggerMode.choices,
default=SyncOutgoingTriggerMode.DEFERRED_END,
help_text=_("When to trigger sync for outgoing providers"),
)
class Meta:
abstract = True

View File

@@ -83,10 +83,6 @@ class OutgoingSyncProvider(ScheduledModel, Model):
def sync_actor(self) -> Actor:
raise NotImplementedError
def sync_dispatch(self) -> None:
for schedule in self.schedules:
schedule.send()
@property
def schedule_specs(self) -> list[ScheduleSpec]:
return [

View File

@@ -1,6 +1,3 @@
from contextlib import contextmanager
from contextvars import ContextVar
from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete
from dramatiq.actor import Actor
@@ -10,23 +7,6 @@ from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.reflection import class_to_path
_CTX_INHIBIT_DISPATCH = ContextVar[bool](
"authentik_sync_outgoing_inhibit_dispatch",
default=False,
)
@contextmanager
def sync_outgoing_inhibit_dispatch():
"""
Prevent direct and m2m tasks from being dispatched when User/Group/membership change
"""
_CTX_INHIBIT_DISPATCH.set(True)
try:
yield
finally:
_CTX_INHIBIT_DISPATCH.set(False)
def register_signals(
provider_type: type[OutgoingSyncProvider],
@@ -48,8 +28,6 @@ def register_signals(
# This primarily happens during user login
if sender == User and update_fields == {"last_login"}:
return
if _CTX_INHIBIT_DISPATCH.get():
return
if not provider_type.objects.exists():
return
task_sync_direct_dispatch.send(
@@ -63,8 +41,6 @@ def register_signals(
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
"""Pre-delete handler"""
if _CTX_INHIBIT_DISPATCH.get():
return
if not provider_type.objects.exists():
return
task_sync_direct_dispatch.send(
@@ -82,8 +58,6 @@ def register_signals(
"""Sync group membership"""
if action not in ["post_add", "post_remove"]:
return
if _CTX_INHIBIT_DISPATCH.get():
return
if not provider_type.objects.exists():
return
task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse)

View File

@@ -129,7 +129,7 @@ class TestConfig(TestCase):
test_value = b' "foo": "bar" '
b64_value = base64.b64encode(test_value)
config.set("foo", b64_value)
self.assertEqual(config.get_dict_from_b64_json("foo"), {})
self.assertEqual(config.get_dict_from_b64_json("foo"), {"foo": "bar"})
def test_get_dict_from_b64_json_invalid(self):
"""Test get_dict_from_b64_json with invalid value"""

View File

@@ -80,26 +80,6 @@ class TestEvaluator(TestCase):
)
self.assertEqual(decoded["preferred_username"], user.username)
def test_expr_create_jwt_raw(self):
"""Test expr_create_jwt_raw"""
rf = RequestFactory()
user = create_test_user()
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
)
evaluator = BaseEvaluator(generate_id())
evaluator._context = {
"http_request": rf.get(reverse("authentik_core:root-redirect")),
"user": user,
"provider": provider.name,
}
jwt = evaluator.evaluate("return ak_create_jwt_raw(provider, foo='bar')")
decoded = decode(
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
)
self.assertEqual(decoded["foo"], "bar")
@patch("authentik.stages.email.tasks.send_mails")
def test_expr_send_email_with_body(self, mock_send_mails):
"""Test ak_send_email with body parameter"""

View File

@@ -26,4 +26,4 @@ def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
for chunk in get_chunks(queryset):
reset_queries()
gc.collect()
yield from chunk.iterator(chunk_size=chunk_size)
yield from chunk.iterator()

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.1.12 on 2025-10-02 12:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("export_ready", "Export Ready"),
("custom_", "Custom Prefix"),
],
default=None,
help_text="Match created events with this action type. When left empty, all action types will be matched.",
null=True,
),
),
]

View File

@@ -313,7 +313,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
"multipart/form-data": SAMLProviderImportSerializer,
},
responses={
201: SAMLProviderSerializer,
204: OpenApiResponse(description="Successfully imported provider"),
400: OpenApiResponse(description="Bad request"),
},
)
@@ -330,18 +330,17 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
file.seek(0)
try:
metadata = ServiceProviderMetadataParser().parse(file.read().decode())
provider = metadata.to_provider(
metadata.to_provider(
body.validated_data["name"],
body.validated_data["authorization_flow"],
body.validated_data["invalidation_flow"],
)
# Return the created provider for use in workflows like the application wizard
return Response(SAMLProviderSerializer(provider).data, status=201)
except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc))
raise ValidationError(
_("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})),
) from None
return Response(status=204)
@permission_required(
"authentik_providers_saml.view_samlprovider",

View File

@@ -145,9 +145,6 @@ class TestSAMLProviderAPI(APITestCase):
def test_import_success(self):
"""Test metadata import (success case)"""
name = generate_id()
authorization_flow = create_test_flow(FlowDesignation.AUTHORIZATION)
invalidation_flow = create_test_flow(FlowDesignation.INVALIDATION)
with TemporaryFile() as metadata:
metadata.write(load_fixture("fixtures/simple.xml").encode())
metadata.seek(0)
@@ -155,18 +152,14 @@ class TestSAMLProviderAPI(APITestCase):
reverse("authentik_api:samlprovider-import-metadata"),
{
"file": metadata,
"name": name,
"authorization_flow": authorization_flow.pk,
"invalidation_flow": invalidation_flow.pk,
"name": generate_id(),
"authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk,
"invalidation_flow": create_test_flow(FlowDesignation.INVALIDATION).pk,
},
format="multipart",
)
self.assertEqual(201, response.status_code)
body = response.json()
self.assertIn("pk", body)
self.assertEqual(body["name"], name)
self.assertEqual(body["authorization_flow"], str(authorization_flow.pk))
self.assertEqual(body["invalidation_flow"], str(invalidation_flow.pk))
self.assertEqual(204, response.status_code)
# We don't test the actual object being created here, that has its own tests
def test_import_failed(self):
"""Test metadata import (invalid xml)"""

View File

@@ -207,6 +207,7 @@ SPECTACULAR_SETTINGS = {
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination",
"PAGE_SIZE": 100,
"DEFAULT_FILTER_BACKENDS": [
"authentik.rbac.filters.ObjectFilter",
"django_filters.rest_framework.DjangoFilterBackend",
@@ -259,7 +260,7 @@ MIDDLEWARE_FIRST = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
]
MIDDLEWARE = [
"authentik.tenants.middleware.DefaultTenantMiddleware",
"django_tenants.middleware.default.DefaultTenantMiddleware",
"authentik.root.middleware.LoggingMiddleware",
"authentik.root.middleware.ClientIPMiddleware",
"authentik.stages.user_login.middleware.BoundSessionMiddleware",

View File

@@ -44,7 +44,6 @@ class KerberosSourceSerializer(SourceSerializer):
"spnego_keytab",
"spnego_ccache",
"password_login_update_internal_password",
"sync_outgoing_trigger_mode",
]
extra_kwargs = {
"sync_password": {"write_only": True},

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-08 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
]
operations = [
migrations.AddField(
model_name="kerberossource",
name="sync_outgoing_trigger_mode",
field=models.TextField(
choices=[
("none", "None"),
("immediate", "Immediate"),
("deferred_end", "Deferred End"),
],
default="deferred_end",
help_text="When to trigger sync for outgoing providers",
),
),
]

View File

@@ -22,14 +22,15 @@ from structlog.stdlib import get_logger
from authentik.core.models import (
GroupSourceConnection,
PropertyMapping,
Source,
UserSourceConnection,
UserTypes,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.challenge import RedirectChallenge
from authentik.lib.sync.incoming.models import IncomingSyncSource
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tasks.schedules.models import ScheduledModel
LOGGER = get_logger()
@@ -45,7 +46,7 @@ class KAdminType(models.TextChoices):
OTHER = "other"
class KerberosSource(IncomingSyncSource):
class KerberosSource(ScheduledModel, Source):
"""Federate Kerberos realm with authentik"""
realm = models.TextField(help_text=_("Kerberos realm"), unique=True)

View File

@@ -6,11 +6,7 @@ from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
from authentik.lib.utils.reflection import all_subclasses
from authentik.sources.kerberos.models import KerberosSource
from authentik.sources.kerberos.sync import KerberosSync
from authentik.tasks.middleware import CurrentTask
@@ -49,15 +45,7 @@ def kerberos_sync(pk: str):
)
return
syncer = KerberosSync(source, self)
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
syncer.sync()
else:
with sync_outgoing_inhibit_dispatch():
syncer.sync()
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
for provider in outgoing_sync_provider_cls.objects.all():
provider.sync_dispatch()
syncer.sync()
except StopSync as exc:
LOGGER.warning("Error syncing kerberos", exc=exc, source=source)
self.error(exc)

View File

@@ -114,7 +114,6 @@ class LDAPSourceSerializer(SourceSerializer):
"connectivity",
"lookup_groups_from_user",
"delete_not_found_objects",
"sync_outgoing_trigger_mode",
]
extra_kwargs = {"bind_password": {"write_only": True}}

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-08 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0010_ldapsource_user_membership_attribute"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="sync_outgoing_trigger_mode",
field=models.TextField(
choices=[
("none", "None"),
("immediate", "Immediate"),
("deferred_end", "Deferred End"),
],
default="deferred_end",
help_text="When to trigger sync for outgoing providers",
),
),
]

View File

@@ -19,14 +19,15 @@ from authentik.core.models import (
Group,
GroupSourceConnection,
PropertyMapping,
Source,
UserSourceConnection,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.sync.incoming.models import IncomingSyncSource
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tasks.schedules.models import ScheduledModel
LDAP_TIMEOUT = 15
LDAP_UNIQUENESS = "ldap_uniq"
@@ -55,7 +56,7 @@ class MultiURLValidator(DomainlessURLValidator):
super().__call__(value)
class LDAPSource(IncomingSyncSource):
class LDAPSource(ScheduledModel, Source):
"""Federate LDAP Directory with authentik, or create new accounts in LDAP."""
server_uri = models.TextField(

View File

@@ -11,11 +11,8 @@ from ldap3.core.exceptions import LDAPException
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
from authentik.lib.utils.reflection import all_subclasses, class_to_path, path_to_class
from authentik.lib.utils.reflection import class_to_path, path_to_class
from authentik.sources.ldap.models import LDAPSource
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
@@ -105,11 +102,6 @@ def ldap_sync(source_pk: str):
timeout=60 * 60 * CONFIG.get_int("ldap.task_timeout_hours") * 1000,
)
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
for provider in outgoing_sync_provider_cls.objects.all():
provider.sync_dispatch()
def ldap_sync_paginator(
task: Task, source: LDAPSource, sync: type[BaseLDAPSynchronizer]
@@ -155,11 +147,7 @@ def ldap_sync_page(source_pk: str, sync_class: str, page_cache_key: str):
self.error(error_message)
return
cache.touch(page_cache_key)
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
count = sync_inst.sync(page)
else:
with sync_outgoing_inhibit_dispatch():
count = sync_inst.sync(page)
count = sync_inst.sync(page)
self.info(f"Synced {count} objects.")
cache.delete(page_cache_key)
except (LDAPException, StopSync) as exc:

View File

@@ -25,7 +25,6 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [
"authentik.sources.oauth.types.slack",
"authentik.sources.oauth.types.twitch",
"authentik.sources.oauth.types.twitter",
"authentik.sources.oauth.types.wechat",
]

View File

@@ -307,15 +307,6 @@ class RedditOAuthSource(CreatableType, OAuthSource):
verbose_name_plural = _("Reddit OAuth Sources")
class WeChatOAuthSource(CreatableType, OAuthSource):
"""Social Login using WeChat."""
class Meta:
abstract = True
verbose_name = _("WeChat OAuth Source")
verbose_name_plural = _("WeChat OAuth Sources")
class OAuthSourcePropertyMapping(PropertyMapping):
"""Map OAuth properties to User or Group object attributes"""

View File

@@ -1,52 +0,0 @@
"""WeChat Type tests"""
from django.test import RequestFactory, TestCase
from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.wechat import WeChatType
WECHAT_USER = {
"openid": "OPENID",
"nickname": "NICKNAME",
"sex": 1,
"province": "PROVINCE",
"city": "CITY",
"country": "COUNTRY",
"headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
"privilege": ["PRIVILEGE1", "PRIVILEGE2"],
"unionid": " o6_buyCrymLUUFYHxvDU6M2PHl22",
}
class TestTypeWeChat(TestCase):
"""OAuth Source tests"""
def setUp(self):
self.source = OAuthSource.objects.create(
name="test",
slug="test",
provider_type="wechat",
)
self.factory = RequestFactory()
def test_enroll_context(self):
"""Test WeChat Enrollment context"""
ak_context = WeChatType().get_base_user_properties(
source=self.source, info=WECHAT_USER, client=None, token={}
)
self.assertEqual(ak_context["username"], WECHAT_USER["unionid"])
self.assertIsNone(ak_context["email"])
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])
self.assertEqual(ak_context["attributes"]["openid"], WECHAT_USER["openid"])
self.assertEqual(ak_context["attributes"]["unionid"], WECHAT_USER["unionid"])
def test_enroll_context_no_unionid(self):
"""Test WeChat Enrollment context without unionid"""
user = WECHAT_USER.copy()
del user["unionid"]
ak_context = WeChatType().get_base_user_properties(
source=self.source, info=user, client=None, token={}
)
self.assertEqual(ak_context["username"], WECHAT_USER["openid"])
self.assertIsNone(ak_context["email"])
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])

View File

@@ -1,162 +0,0 @@
"""WeChat (Weixin) OAuth Views"""
from typing import Any
from requests.exceptions import RequestException
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect
class WeChatOAuthRedirect(OAuthRedirect):
"""WeChat OAuth2 Redirect"""
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
# WeChat (Weixin) for Websites official documentation requires 'snsapi_login'
# as the *only* scope for the QR code-based login flow.
# Ref: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html (Step 1) # noqa: E501
return {
"scope": ["snsapi_login"],
}
class WeChatOAuth2Client(OAuth2Client):
"""
WeChat OAuth2 Client
Handles the non-standard parts of the WeChat OAuth2 flow.
"""
def get_access_token(self, redirect_uri: str, code: str) -> dict[str, Any]:
"""
Get access token from WeChat.
WeChat uses a non-standard GET request for the token exchange,
unlike the standard OAuth2 POST request. The AppID (client_id)
and AppSecret (client_secret) are passed as URL query parameters.
"""
token_url = self.get_access_token_url()
params = {
"appid": self.get_client_id(),
"secret": self.get_client_secret(),
"code": code,
"grant_type": "authorization_code",
}
# Send the GET request using the base class's session handler
response = self.do_request("get", token_url, params=params)
try:
response.raise_for_status()
except RequestException as exc:
self.logger.warning("Unable to fetch wechat token", exc=exc)
raise exc
data = response.json()
# Handle WeChat's specific error format (JSON with 'errcode' and 'errmsg')
if "errcode" in data:
self.logger.warning(
"Unable to fetch wechat token",
errcode=data.get("errcode"),
errmsg=data.get("errmsg"),
)
raise RequestException(data.get("errmsg"))
return data
def get_profile_info(self, token: dict[str, Any]) -> dict[str, Any]:
"""
Get Userinfo from WeChat.
This API call requires both the 'access_token' and the 'openid'
(which was returned during the token exchange).
"""
profile_url = self.get_profile_url()
params = {
"access_token": token.get("access_token"),
"openid": token.get("openid"),
"lang": "en", # or 'zh_CN' (Simplified Chinese), 'zh_TW' (Traditional)
}
response = self.do_request("get", profile_url, params=params)
try:
response.raise_for_status()
except RequestException as exc:
self.logger.warning("Unable to fetch wechat userinfo", exc=exc)
raise exc
data = response.json()
# Handle WeChat's specific error format
if "errcode" in data:
self.logger.warning(
"Unable to fetch wechat userinfo",
errcode=data.get("errcode"),
errmsg=data.get("errmsg"),
)
raise RequestException(data.get("errmsg"))
return data
class WeChatOAuth2Callback(OAuthCallback):
"""WeChat OAuth2 Callback"""
# Specify our custom Client to handle the non-standard WeChat flow
client_class = WeChatOAuth2Client
@registry.register()
class WeChatType(SourceType):
"""WeChat Type definition"""
callback_view = WeChatOAuth2Callback
redirect_view = WeChatOAuthRedirect
verbose_name = "WeChat"
name = "wechat"
# WeChat API URLs are fixed and not customizable
urls_customizable = False
# URLs for the WeChat "Login for Websites" authorization flow
authorization_url = "https://open.weixin.qq.com/connect/qrconnect"
# nosec: B105 This is a public URL, not a hardcoded secret
access_token_url = "https://api.weixin.qq.com/sns/oauth2/access_token" # nosec
profile_url = "https://api.weixin.qq.com/sns/userinfo"
# Note: 'authorization_code_auth_method' is intentionally omitted.
# The base OAuth2Client defaults to POST_BODY, but our custom
# WeChatOAuth2Client overrides get_access_token() to use GET,
# so this setting would be misleading.
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
"""
Map WeChat userinfo to authentik user properties.
"""
# The WeChat userinfo API (sns/userinfo) does *not* return an email address.
# We explicitly set 'email' to None. Authentik will typically
# prompt the user to provide one on their first login if it's required.
# 'unionid' is the preferred unique identifier as it's consistent
# across multiple apps under the same WeChat Open Platform account.
# 'openid' is the fallback, which is only unique to this specific AppID.
return {
"username": info.get("unionid", info.get("openid")),
"email": None, # WeChat API does not provide Email
"name": info.get("nickname"),
"attributes": {
# Save all other relevant info as user attributes
"headimgurl": info.get("headimgurl"),
"sex": info.get("sex"),
"city": info.get("city"),
"province": info.get("province"),
"country": info.get("country"),
"unionid": info.get("unionid"),
"openid": info.get("openid"),
},
}

View File

@@ -3,6 +3,7 @@
from typing import Any
from uuid import UUID
from django.conf import settings
from django.core.paginator import Page, Paginator
from django.db.models import Q, QuerySet
from django.http import HttpRequest
@@ -85,9 +86,10 @@ class SCIMView(APIView):
)
def paginate_query(self, query: QuerySet) -> Page:
per_page = int(self.request.tenant.pagination_default_page_size)
per_page = 50
start_index = 1
try:
per_page = int(settings.REST_FRAMEWORK["PAGE_SIZE"])
start_index = int(self.request.query_params.get("startIndex", 1))
except ValueError:
pass

View File

@@ -37,7 +37,7 @@ class ServiceProviderConfigView(SCIMView):
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": {
"supported": True,
"maxResults": request.tenant.pagination_default_page_size,
"maxResults": int(settings.REST_FRAMEWORK["PAGE_SIZE"]),
},
"changePassword": {"supported": False},
"sort": {"supported": False},

View File

@@ -1,17 +1,8 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.telegram.api.source_connection import UserTelegramSourceConnectionSerializer
from authentik.sources.telegram.models import TelegramSource, UserTelegramSourceConnection
from authentik.sources.telegram.telegram import TelegramAuth
from authentik.sources.telegram.models import TelegramSource
class TelegramSourceSerializer(SourceSerializer):
@@ -28,16 +19,6 @@ class TelegramSourceSerializer(SourceSerializer):
}
class TelegramAuthSerializer(TelegramAuth):
def __init__(self, *args, **kwargs):
self._bot_token = kwargs.pop("bot_token", None)
super().__init__(*args, **kwargs)
def get_bot_token(self):
return self._bot_token
class TelegramSourceViewSet(UsedByMixin, ModelViewSet):
queryset = TelegramSource.objects.all()
serializer_class = TelegramSourceSerializer
@@ -58,38 +39,3 @@ class TelegramSourceViewSet(UsedByMixin, ModelViewSet):
]
search_fields = ["name", "slug"]
ordering = ["name"]
@extend_schema(
request=TelegramAuthSerializer,
responses={
201: UserTelegramSourceConnectionSerializer,
403: OpenApiResponse(description="Access denied"),
},
)
@action(
methods=["POST"],
url_path="connect_user",
detail=True,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
)
def connect_user(self, request: Request, slug: str) -> Response:
source: TelegramSource = get_object_or_404(TelegramSource, slug=slug)
serializer = TelegramAuthSerializer(bot_token=source.bot_token, data=request.data)
serializer.is_valid(raise_exception=True)
connection, created = UserTelegramSourceConnection.objects.get_or_create(
source=source,
identifier=serializer.validated_data["id"],
defaults={"user": request.user},
)
if not created and connection.user != request.user:
return Response(
data={"detail": _("This Telegram account is already connected to another user.")},
status=403,
)
return Response(
data=UserTelegramSourceConnectionSerializer(instance=connection).data, status=201
)

View File

@@ -1,7 +1,6 @@
"""Telegram source"""
from typing import Any
from urllib.parse import urlencode
from django.db import models
from django.http import HttpRequest
@@ -76,12 +75,6 @@ class TelegramSource(Source):
"title": self.name,
"component": "ak-user-settings-source-telegram",
"icon_url": self.icon_url,
"configure_url": urlencode(
{
"bot_username": self.bot_username,
"request_message_access": str(self.request_message_access),
}
),
}
)

View File

@@ -1,8 +1,12 @@
import hashlib
import hmac
from datetime import datetime, timedelta
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import BooleanField, CharField
from rest_framework.fields import BooleanField, CharField, IntegerField, URLField
from rest_framework.serializers import ValidationError
from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.sources.telegram.telegram import TelegramAuth
from authentik.stages.identification.stage import LoginChallengeMixin
@@ -12,15 +16,33 @@ class TelegramLoginChallenge(LoginChallengeMixin, Challenge):
request_message_access = BooleanField()
class TelegramChallengeResponse(TelegramAuth, ChallengeResponse):
class TelegramChallengeResponse(ChallengeResponse):
component = CharField(default="ak-source-telegram")
def get_bot_token(self) -> str:
return self.stage.source.bot_token
id = IntegerField()
first_name = CharField(max_length=255, required=False)
last_name = CharField(max_length=255, required=False)
username = CharField(max_length=255, required=False)
photo_url = URLField(required=False)
auth_date = IntegerField(required=True)
hash = CharField(max_length=64, required=True)
def validate_auth_date(self, auth_date: int) -> int:
if datetime.fromtimestamp(auth_date) < datetime.now() - timedelta(minutes=5):
raise ValidationError(_("Authentication date is too old"))
return auth_date
def validate(self, attrs: dict) -> dict:
# Check the response as defined in https://core.telegram.org/widgets/login
attrs_to_check = attrs.copy()
component = attrs_to_check.pop("component")
validated = super().validate(attrs_to_check)
validated["component"] = component
return validated
attrs_to_check.pop("component")
attrs_to_check.pop("hash")
check_str = "\n".join([f"{key}={value}" for key, value in sorted(attrs_to_check.items())])
digest = hmac.new(
hashlib.sha256(self.stage.source.bot_token.encode("utf-8")).digest(),
check_str.encode("utf-8"),
"sha256",
).hexdigest()
if not hmac.compare_digest(digest, attrs["hash"]):
raise ValidationError(_("Invalid hash"))
return attrs

View File

@@ -1,36 +0,0 @@
import hashlib
import hmac
from datetime import datetime, timedelta
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField, IntegerField, URLField
from rest_framework.serializers import Serializer, ValidationError
class TelegramAuth(Serializer):
id = IntegerField()
first_name = CharField(max_length=255, required=False)
last_name = CharField(max_length=255, required=False)
username = CharField(max_length=255, required=False)
photo_url = URLField(required=False)
auth_date = IntegerField(required=True)
hash = CharField(max_length=64, required=True)
def validate_auth_date(self, auth_date: int) -> int:
if datetime.fromtimestamp(auth_date) < datetime.now() - timedelta(minutes=5):
raise ValidationError(_("Authentication date is too old"))
return auth_date
def validate(self, attrs: dict) -> dict:
# Check the response as defined in https://core.telegram.org/widgets/login
check_str = "\n".join(
[f"{key}={value}" for key, value in sorted(attrs.items()) if key != "hash"]
)
digest = hmac.new(
hashlib.sha256(self.get_bot_token().encode("utf-8")).digest(),
check_str.encode("utf-8"),
"sha256",
).hexdigest()
if not hmac.compare_digest(digest, attrs["hash"]):
raise ValidationError(_("Invalid hash"))
return attrs

View File

@@ -9,10 +9,9 @@ from django.test import TestCase
from django.urls import reverse
from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.sources.telegram.models import UserTelegramSourceConnection
from authentik.sources.telegram.stage import TelegramChallengeResponse
from authentik.stages.identification.models import IdentificationStage, UserFields
@@ -184,23 +183,3 @@ class TestTelegramViews(MockTelegramResponseMixin, FlowTestCase):
"authentik_core:if-flow", kwargs={"flow_slug": self.source.enrollment_flow.slug}
),
)
def test_connect_user(self):
user = create_test_user("testuser")
user2 = create_test_user("testuser2")
self.client.force_login(user)
response = self.client.post(
reverse("authentik_api:telegramsource-connect-user", args=[self.source.slug]),
self._make_valid_response(),
)
self.assertEqual(response.status_code, 201)
self.assertTrue(
UserTelegramSourceConnection.objects.filter(user=user, source=self.source).exists()
)
self.client.logout()
self.client.force_login(user2)
response = self.client.post(
reverse("authentik_api:telegramsource-connect-user", args=[self.source.slug]),
self._make_valid_response(),
)
self.assertEqual(response.status_code, 403)

View File

@@ -152,17 +152,11 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
return device
def validate_challenge_webauthn(
data: dict,
stage_view: StageView,
user: User,
stage: AuthenticatorValidateStage | None = None,
) -> Device:
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
"""Validate WebAuthn Challenge"""
request = stage_view.request
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
stage = stage or stage_view.executor.current_stage
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
if "MinuteMaid" in request.META.get("HTTP_USER_AGENT", ""):
# Workaround for Android sign-in, when signing into Google Workspace on android while
# adding the account to the system (not in Chrome), for some reason `type` is not set

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -255,7 +255,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
def test_register_restricted_device_type_allow_unknown(self):
"""Test registration with restricted devices (allow, unknown device type)"""
webauthn_mds_import.send(force=True)
WebAuthnDeviceType.objects.filter(aaguid="fbfc3007-154e-4ecc-8c0b-6e020557d7bd").delete()
WebAuthnDeviceType.objects.filter(description="iCloud Keychain").delete()
self.stage.device_type_restrictions.set(
WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID)
)

View File

@@ -18,13 +18,6 @@
{{ body }}
</td>
</tr>
{% if link %}
<tr>
<td align="center" class="btn btn-primary">
<a id="confirm" href="{{ link.target }}" rel="noopener noreferrer" target="_blank">{{ link.label }}</a>
</td>
</tr>
{% endif %}
{% if key_value %}
<tr>
<td>

View File

@@ -37,7 +37,6 @@ class IdentificationStageSerializer(StageSerializer):
"show_source_labels",
"pretend_user_exists",
"enable_remember_me",
"webauthn_stage",
]
@@ -50,7 +49,6 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet):
"name",
"password_stage",
"captcha_stage",
"webauthn_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",

Some files were not shown because too many files have changed in this diff Show More