Compare commits

..

1 Commits

Author SHA1 Message Date
Marc 'risson' Schmitt
880457aadf root: add flake
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-01-05 16:18:12 +01:00
1797 changed files with 45143 additions and 97806 deletions

View File

@@ -1,35 +0,0 @@
[licenses]
allow = ["Apache-2.0", "MIT", "MPL-2.0", "Unicode-3.0"]
[licenses.private]
ignore = true
[bans]
multiple-versions = "allow"
wildcards = "deny"
[bans.workspace-dependencies]
duplicates = "deny"
include-path-dependencies = true
unused = "deny"
# No non-FIPS compliant dependencies
[[bans.deny]]
name = "native-tls"
[[bans.deny]]
name = "openssl"
[[bans.deny]]
name = "openssl-sys"
[[bans.deny]]
name = "ring"
[[bans.features]]
allow = [
"alloc",
"aws-lc-sys",
"default",
"fips",
"prebuilt-nasm",
"ring-io",
"ring-sig-verify",
]
name = "aws-lc-rs"
exact = true

View File

@@ -1,16 +0,0 @@
comment_width = 100
format_code_in_doc_comments = true
format_strings = true
group_imports = "StdExternalCrate"
hex_literal_case = "Lower"
imports_granularity = "Crate"
max_width = 100
newline_style = "Unix"
normalize_comments = true
normalize_doc_attributes = true
reorder_impl_items = true
style_edition = "2024"
use_field_init_shorthand = true
use_try_shorthand = true
where_single_line = true
wrap_comments = true

View File

@@ -115,13 +115,20 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
REASON: ${{ steps.should_run.outputs.reason }}
run: |
set -e -o pipefail
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
# Get PR details
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
# Determine which labels to process
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
# Only process the specific label that was just added
if [ "${{ github.event_name }}" = "issues" ]; then
LABEL_NAME="${{ github.event.label.name }}"
@@ -145,13 +152,13 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
LABELS: '${{ steps.pr_details.outputs.labels }}'
run: |
set -e -o pipefail
PR_NUMBER='${{ steps.should_run.outputs.pr_number }}'
COMMIT_SHA='${{ steps.should_run.outputs.merge_commit_sha }}'
PR_TITLE='${{ steps.pr_details.outputs.pr_title }}'
PR_AUTHOR='${{ steps.pr_details.outputs.pr_author }}'
LABELS='${{ steps.pr_details.outputs.labels }}'
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
echo "Found backport labels: $LABELS"
@@ -208,9 +215,6 @@ runs:
--head "$CHERRY_PICK_BRANCH" \
--label "cherry-pick")
# Assign the PR to the original author
gh pr edit "$NEW_PR" --add-assignee "$PR_AUTHOR" || true
echo "✅ Created cherry-pick PR $NEW_PR for $TARGET_BRANCH"
# Comment on original PR
@@ -250,9 +254,6 @@ runs:
--head "$CHERRY_PICK_BRANCH" \
--label "cherry-pick")
# Assign the PR to the original author
gh pr edit "$NEW_PR" --add-assignee "$PR_AUTHOR" || true
echo "⚠️ Created conflict resolution PR $NEW_PR for $TARGET_BRANCH"
# Comment on original PR

View File

@@ -4,7 +4,7 @@ description: "Setup authentik testing environment"
inputs:
dependencies:
description: "List of dependencies to setup"
default: "system,python,rust,node,go,runtime"
default: "system,python,node,go,runtime"
postgresql_version:
description: "Optional postgresql image tag"
default: "16"
@@ -18,62 +18,33 @@ runs:
run: |
sudo apt-get remove --purge man-db
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v5
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
with:
enable-cache: true
- name: Setup python
if: ${{ contains(inputs.dependencies, 'python') }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
with:
python-version-file: "pyproject.toml"
- name: Install Python deps
if: ${{ contains(inputs.dependencies, 'python') }}
shell: bash
run: uv sync --all-extras --dev --frozen
- name: Setup rust (stable)
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
with:
rustflags: ""
- name: Setup rust (nightly)
if: ${{ contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
with:
toolchain: nightly
components: rustfmt
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@06203676c62f0d3c765be3f2fcfbebbcb02d09f5 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
- name: Setup node
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
registry-url: "https://registry.npmjs.org"
- name: Setup node (root)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
if: ${{ contains(inputs.dependencies, 'node') }}
shell: bash
run: npm ci
registry-url: 'https://registry.npmjs.org'
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
with:
go-version-file: "go.mod"
- name: Setup docker cache
@@ -87,7 +58,7 @@ runs:
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
cd web && npm ci
cd web && npm i
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
shell: uv run python {0}

View File

@@ -22,7 +22,7 @@ services:
- 8020:8000
volumes:
- s3-data:/usr/src/app/localData
- s3-metadata:/usr/src/app/localMetadata
- s3-metadata:/usr/scr/app/localMetadata
restart: always
volumes:

View File

@@ -2,22 +2,18 @@ name: "Process test results"
description: Convert test results to JUnit, add them to GitHub Actions and codecov
inputs:
files:
description: Comma-separated explicit list of files to upload
flags:
description: Codecov flags
runs:
using: "composite"
steps:
- uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
files: ${{ inputs.files }}
flags: ${{ inputs.flags }}
use_oidc: true
- uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
files: ${{ inputs.files }}
flags: ${{ inputs.flags }}
use_oidc: true
report_type: test_results

1
.github/codespell-dictionary.txt vendored Normal file
View File

@@ -0,0 +1 @@
authentic->authentik

32
.github/codespell-words.txt vendored Normal file
View File

@@ -0,0 +1,32 @@
akadmin
asgi
assertIn
authentik
authn
crate
docstrings
entra
goauthentik
gunicorn
hass
jwe
jwks
keypair
keypairs
kubernetes
oidc
ontext
openid
passwordless
plex
saml
scim
singed
slo
sso
totp
traefik
# https://github.com/codespell-project/codespell/issues/1224
upToDate
warmup
webauthn

View File

@@ -38,21 +38,6 @@ updates:
#endregion
#region Rust
- package-ecosystem: rust-toolchain
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "core:"
labels:
- dependencies
#endregion
#region Web
- package-ecosystem: npm
@@ -249,7 +234,7 @@ updates:
- package-ecosystem: docker
directories:
- /lifecycle/container
- /
- /website
schedule:
interval: daily

View File

@@ -42,9 +42,9 @@ jobs:
# Needed for checkout
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -56,31 +56,32 @@ jobs:
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- name: make empty clients
if: ${{ inputs.release }}
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Setup node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: generate ts client
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
id: push
with:
context: .
@@ -95,7 +96,7 @@ jobs:
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -49,7 +49,7 @@ jobs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -69,7 +69,7 @@ jobs:
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -79,25 +79,25 @@ jobs:
image-name: ${{ inputs.image_name }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@8aac06098a12365ccdf99372dcfb453ccce8a0b0 # v2
- uses: int128/docker-manifest-create-action@6cdd53a8337cd50bc3ef8c7016579d8d460edd94 # v2
id: build
with:
tags: ${{ matrix.tag }}
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}

View File

@@ -18,14 +18,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"
@@ -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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install Dependencies
working-directory: website/
run: npm ci
@@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -41,7 +41,7 @@ jobs:
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with:
path: |
${{ github.workspace }}/website/api/.docusaurus
@@ -55,7 +55,7 @@ jobs:
env:
NODE_ENV: production
run: npm run build -w api
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
with:
name: api-docs
path: website/api/build
@@ -66,12 +66,12 @@ jobs:
- lint
- build
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

@@ -21,10 +21,10 @@ jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: lifecycle/aws/package.json
cache: "npm"

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate docs

View File

@@ -15,15 +15,13 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
env:
NODE_ENV: production
strategy:
fail-fast: false
matrix:
command:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install dependencies
working-directory: website/
run: npm ci
@@ -32,11 +30,10 @@ jobs:
run: npm run ${{ matrix.command }}
build-docs:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -49,11 +46,10 @@ jobs:
run: npm run build
build-integrations:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -73,13 +69,13 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -89,14 +85,14 @@ jobs:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
@@ -105,7 +101,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -6,10 +6,6 @@ on:
schedule:
# Every night at 3am
- cron: "0 3 * * *"
pull_request:
paths:
# Needs to refer to itself
- .github/workflows/ci-main-daily.yml
jobs:
test-container:
@@ -19,14 +15,14 @@ jobs:
matrix:
version:
- docs
- version-2025-12
- version-2025-10
- version-2025-4
- version-2025-2
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"
mkdir -p "${dir}/lifecycle/container"
cd "${dir}"
wget "https://${{ matrix.version }}.goauthentik.io/docker-compose.yml" -O "${dir}/lifecycle/container/compose.yml"
"${current}/scripts/test_docker.sh"
mkdir -p $dir
cd $dir
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
${current}/scripts/test_docker.sh

View File

@@ -28,50 +28,24 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- job: bandit
deps: python
- job: black
deps: python
- job: spellcheck
deps: node
- job: pending-migrations
deps: python,runtime
- job: ruff
deps: python
- job: mypy
deps: python
- job: cargo-deny
deps: rust
- job: cargo-machete
deps: rust
- job: clippy
deps: rust
- job: rustfmt
deps: rust-nightly
job:
- bandit
- black
- codespell
- pending-migrations
- ruff
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: ${{ matrix.deps }}
- name: run job
run: make ci-lint-${{ matrix.job }}
test-gen-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate schema
run: make migrate gen-build
- name: ensure schema is up-to-date
run: git diff --exit-code -- schema.yml blueprints/schema.json
run: uv run make ci-${{ matrix.job }}
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
@@ -97,7 +71,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 0
- name: checkout stable
@@ -110,7 +84,7 @@ jobs:
# Current version family based on
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
if [[ -n $current_version_family ]]; then
prev_stable="version/${current_version_family}"
prev_stable=$current_version_family
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
@@ -162,7 +136,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
@@ -182,11 +156,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0
- name: run integration
run: |
uv run coverage run manage.py test tests/integration
@@ -213,8 +187,6 @@ jobs:
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
- name: ldap
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: ws-fed
glob: tests/e2e/test_provider_ws_fed*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
@@ -224,14 +196,14 @@ jobs:
- name: endpoints
glob: tests/e2e/test_endpoints_*
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -264,7 +236,7 @@ jobs:
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
@@ -274,7 +246,7 @@ jobs:
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -295,38 +267,14 @@ jobs:
with:
flags: conformance
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/
test-rust:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: rust
- name: run tests
run: |
cargo llvm-cov --no-report nextest --workspace
cargo llvm-cov report --codecov --output-path target/llvm-cov-target/rust.json
- uses: ./.github/actions/test-results
if: ${{ always() }}
with:
files: target/llvm-cov-target/rust.json
flags: rust
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-rust
path: target/llvm-cov-target/rust.json
ci-core-mark:
if: always()
needs:
- lint
- test-gen-build
- test-migrations
- test-migrations-from-stable
- test-unittest
@@ -362,7 +310,7 @@ jobs:
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables

View File

@@ -21,8 +21,8 @@ jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@@ -42,8 +42,8 @@ jobs:
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- name: Setup authentik env
@@ -86,13 +86,13 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -102,7 +102,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -111,7 +111,7 @@ jobs:
run: make gen-client-go
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
@@ -122,7 +122,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
@@ -145,13 +145,13 @@ jobs:
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -31,8 +31,8 @@ jobs:
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
@@ -48,8 +48,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -76,8 +76,8 @@ jobs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -29,20 +29,20 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
id: compress
uses: calibreapp/image-actions@03c976c29803442fc4040a9de5509669e7759b81 # main
uses: calibreapp/image-actions@420075c115b26f8785e293c5bd5bef0911c506e5 # main
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@@ -16,17 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: uv run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -10,14 +10,14 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
if: ${{ env.GH_APP_ID != '' }}
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
env:
GH_APP_ID: ${{ secrets.GH_APP_ID }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ steps.app-token.outcome != 'skipped' }}
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Cleanup
run: |

View File

@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Delete 'dev' containers older than a week
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
with:

View File

@@ -29,19 +29,18 @@ jobs:
- packages/eslint-config
- packages/prettier-config
- packages/docusaurus-config
- packages/logger-js
- packages/esbuild-plugin-live-reload
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json

View File

@@ -24,7 +24,7 @@ jobs:
language: ["go", "javascript", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Initialize CodeQL

View File

@@ -26,5 +26,5 @@ jobs:
image: semgrep/semgrep
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: semgrep ci

View File

@@ -29,12 +29,12 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: "${{ steps.app-token.outputs.token }}"
@@ -57,12 +57,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: ${{ steps.generate_token.outputs.token }}
@@ -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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: release-bump-${{ inputs.next_version }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
environment: internal-production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
- run: |

View File

@@ -31,11 +31,11 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -44,21 +44,21 @@ jobs:
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
if: true
with:
@@ -83,19 +83,14 @@ jobs:
- radius
- rac
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -103,23 +98,23 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
- name: Generate API Clients
- name: make empty clients
run: |
make gen-client-ts
make gen-client-go
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Docker Login Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
id: push
with:
push: true
@@ -129,7 +124,7 @@ jobs:
file: lifecycle/container/${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
@@ -151,26 +146,19 @@ jobs:
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web/
run: |
npm ci
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: Build web
working-directory: web/
run: |
npm ci
npm run build-proxy
- name: Build outpost
run: |
@@ -180,7 +168,7 @@ jobs:
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
@@ -198,8 +186,8 @@ jobs:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: ${{ env.AWS_REGION }}
@@ -214,15 +202,15 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
docker compose -f lifecycle/container/compose.yml pull -q
docker compose -f lifecycle/container/compose.yml up --no-start
docker compose -f lifecycle/container/compose.yml start postgresql
docker compose -f lifecycle/container/compose.yml run -u root server test-all
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql
docker compose run -u root server test-all
sentry-release:
needs:
- build-server
@@ -230,7 +218,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev

View File

@@ -52,11 +52,9 @@ jobs:
needs:
- check-inputs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
- name: Setup authentik env
uses: ./.github/actions/setup
- run: make test-docker
bump-authentik:
name: Bump authentik version
@@ -67,16 +65,16 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- id: get-user-id
name: Get GitHub app user ID
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
token: "${{ steps.app-token.outputs.token }}"
@@ -91,12 +89,11 @@ jobs:
# ID from https://api.github.com/users/authentik-automation[bot]
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
git pull
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
git push --follow-tags
- name: Create Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
token: "${{ steps.app-token.outputs.token }}"
tag_name: "version/${{ inputs.version }}"
@@ -115,17 +112,17 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
repositories: helm
- id: get-user-id
name: Get GitHub app user ID
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/helm"
token: "${{ steps.app-token.outputs.token }}"
@@ -137,7 +134,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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -157,17 +154,17 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
repositories: version
- id: get-user-id
name: Get GitHub app user ID
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/version"
token: "${{ steps.app-token.outputs.token }}"
@@ -175,28 +172,24 @@ jobs:
if: "${{ inputs.release_reason == 'feature' }}"
run: |
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
reason="${{ inputs.release_reason }}"
jq \
--arg version "${{ inputs.version }}" \
--arg changelog "See ${changelog_url}" \
--arg changelog_url "${changelog_url}" \
--arg reason "${reason}" \
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
mv version.new.json version.json
- name: Bump version
if: "${{ inputs.release_reason != 'feature' }}"
run: |
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
reason="${{ inputs.release_reason }}"
jq \
--arg version "${{ inputs.version }}" \
--arg changelog "See ${changelog_url}" \
--arg changelog_url "${changelog_url}" \
--arg reason "${reason}" \
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
'.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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}

View File

@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
repo-token: ${{ steps.generate_token.outputs.token }}
days-before-stale: 60

View File

@@ -21,15 +21,15 @@ jobs:
steps:
- id: generate_token
if: ${{ github.event_name != 'pull_request' }}
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
@@ -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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

21
.gitignore vendored
View File

@@ -15,9 +15,6 @@ media
node_modules
.cspellcache
cspell-report.*
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/
@@ -195,24 +192,6 @@ pyvenv.cfg
pip-selfcheck.json
# End of https://www.gitignore.io/api/python,django
# Created by https://www.toptal.com/developers/gitignore/api/rust
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# End of https://www.toptal.com/developers/gitignore/api/rust
/static/
local.env.yml

12
.vscode/settings.json vendored
View File

@@ -14,10 +14,6 @@
"[xml]": {
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
},
"files.associations": {
// The built-in "ignore" language gives us enough syntax highlighting to make these files readable.
"**/dictionaries/*.txt": "ignore"
},
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [
@@ -53,9 +49,13 @@
"ignoreCase": false
}
],
"go.testFlags": ["-count=1"],
"go.testFlags": [
"-count=1"
],
"go.testEnvVars": {
"WORKSPACE_DIR": "${workspaceFolder}"
},
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
"github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
]
}

View File

@@ -3,7 +3,6 @@
# Backend
authentik/ @goauthentik/backend
blueprints/ @goauthentik/backend
src/ @goauthentik/backend
cmd/ @goauthentik/backend
internal/ @goauthentik/backend
lifecycle/ @goauthentik/backend
@@ -12,12 +11,8 @@ scripts/ @goauthentik/backend
tests/ @goauthentik/backend
pyproject.toml @goauthentik/backend
uv.lock @goauthentik/backend
Cargo.toml @goauthentik/backend
Cargo.lock @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
.cargo/ @goauthentik/backend
rust-toolchain.toml @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
@@ -39,7 +34,6 @@ packages/docusaurus-config @goauthentik/frontend
packages/esbuild-plugin-live-reload @goauthentik/frontend
packages/eslint-config @goauthentik/frontend
packages/prettier-config @goauthentik/frontend
packages/logger-js @goauthentik/frontend
packages/tsconfig @goauthentik/frontend
# Web
web/ @goauthentik/frontend

271
Cargo.lock generated
View File

@@ -1,271 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys",
]
[[package]]
name = "docsmg"
version = "0.0.0"
dependencies = [
"clap",
"colored",
"dotenvy",
"eyre",
"regex",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indenter"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

View File

@@ -1,133 +0,0 @@
[workspace]
members = ["website/scripts/docsmg"]
resolver = "3"
[workspace.package]
authors = ["authentik Team <hello@goauthentik.io>"]
edition = "2024"
readme = "README.md"
homepage = "https://goauthentik.io"
repository = "https://github.com/goauthentik/authentik.git"
license-file = "LICENSE"
publish = false
[workspace.dependencies]
clap = { version = "4.5.59", features = ["derive", "env"] }
colored = "3.1.1"
dotenvy = "0.15.7"
eyre = "0.6.12"
regex = "1.12.3"
[profile.dev.package.backtrace]
opt-level = 3
[profile.release]
lto = true
debug = 2
[workspace.lints.rust]
ambiguous_negative_literals = "warn"
closure_returning_async_block = "warn"
macro_use_extern_crate = "deny"
# must_not_suspend = "deny", unstable see https://github.com/rust-lang/rust/issues/83310
non_ascii_idents = "deny"
redundant_imports = "warn"
semicolon_in_expressions_from_macros = "warn"
trivial_casts = "warn"
trivial_numeric_casts = "warn"
unit_bindings = "warn"
unreachable_pub = "warn"
unsafe_code = "deny"
unused_extern_crates = "warn"
unused_import_braces = "warn"
unused_lifetimes = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
[workspace.lints.rustdoc]
unescaped_backticks = "warn"
[workspace.lints.clippy]
### enable all lints
cargo = { priority = -1, level = "warn" }
complexity = { priority = -1, level = "warn" }
correctness = { priority = -1, level = "warn" }
nursery = { priority = -1, level = "warn" }
pedantic = { priority = -1, level = "warn" }
perf = { priority = -1, level = "warn" }
# Those are too restrictive and disabled by default, however we enable some below
# restriction = { priority = -1, level = "warn" }
style = { priority = -1, level = "warn" }
suspicious = { priority = -1, level = "warn" }
### and disable the ones we don't want
### pedantic group
redundant_closure_for_method_calls = "allow"
too_many_lines = "allow"
### nursery
redundant_pub_crate = "allow"
option_if_let_else = "allow"
### restriction group
allow_attributes = "warn"
allow_attributes_without_reason = "warn"
as_conversions = "warn"
as_pointer_underscore = "warn"
as_underscore = "warn"
assertions_on_result_states = "warn"
clone_on_ref_ptr = "warn"
create_dir = "warn"
dbg_macro = "warn"
default_numeric_fallback = "warn"
disallowed_script_idents = "warn"
doc_paragraphs_missing_punctuation = "warn"
empty_drop = "warn"
empty_enum_variants_with_brackets = "warn"
empty_structs_with_brackets = "warn"
error_impl_error = "warn"
exit = "warn"
filetype_is_file = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
if_then_some_else_none = "warn"
impl_trait_in_params = "warn"
infinite_loop = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
mem_forget = "warn"
missing_asserts_for_indexing = "warn"
missing_trait_methods = "warn"
mixed_read_write_in_expression = "warn"
mutex_atomic = "warn"
mutex_integer = "warn"
needless_raw_strings = "warn"
non_zero_suggestions = "warn"
panic_in_result_fn = "warn"
pathbuf_init_then_push = "warn"
print_stdout = "warn"
rc_buffer = "warn"
redundant_test_prefix = "warn"
redundant_type_annotations = "warn"
ref_patterns = "warn"
renamed_function_params = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_and_then = "warn"
same_name_method = "warn"
semicolon_inside_block = "warn"
str_to_string = "warn"
string_add = "warn"
suspicious_xor_used_as_pow = "warn"
tests_outside_test_module = "warn"
todo = "warn"
try_err = "warn"
undocumented_unsafe_blocks = "warn"
unimplemented = "warn"
unnecessary_safety_comment = "warn"
unnecessary_safety_doc = "warn"
unnecessary_self_imports = "warn"
unneeded_field_pattern = "warn"
unseparated_literal_suffix = "warn"
unused_result_ok = "warn"
unused_trait_names = "warn"
unwrap_in_result = "warn"
unwrap_used = "warn"
verbose_file_reads = "warn"

191
Makefile
View File

@@ -5,6 +5,7 @@ SHELL := /usr/bin/env bash
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.generate_semver)
PY_SOURCES = authentik packages tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
@@ -19,43 +20,24 @@ GEN_API_TS = gen-ts-api
GEN_API_PY = gen-py-api
GEN_API_GO = gen-go-api
BREW_LDFLAGS :=
BREW_CPPFLAGS :=
BREW_PKG_CONFIG_PATH :=
CARGO := cargo
UV := uv
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
ifeq ($(UNAME_S),Darwin)
# Only add for brew users who installed libxmlsec1
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
ifdef BREW_EXISTS
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
ifdef LIBXML2_EXISTS
_xml_pref := $(shell brew --prefix libxml2)
BREW_LDFLAGS += -L${_xml_pref}/lib
BREW_CPPFLAGS += -I${_xml_pref}/include
BREW_PKG_CONFIG_PATH = ${_xml_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
KRB5_EXISTS := $(shell brew list krb5 2> /dev/null)
ifdef KRB5_EXISTS
_krb5_pref := $(shell brew --prefix krb5)
BREW_LDFLAGS += -L${_krb5_pref}/lib
BREW_CPPFLAGS += -I${_krb5_pref}/include
BREW_PKG_CONFIG_PATH = ${_krb5_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
UV := LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv
endif
endif
# These functions are only evaluated when called in specific targets
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
NPM_VERSION :=
UV_EXISTS := $(shell command -v uv 2> /dev/null)
ifdef UV_EXISTS
NPM_VERSION := $(shell $(UV) run python -m scripts.generate_semver)
else
NPM_VERSION = $(shell python -m scripts.generate_semver)
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
KRB_PATH =
ifneq ($(KRB5_EXISTS),)
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
@@ -70,54 +52,51 @@ help: ## Show this help
sort
@echo ""
go-test: ## Run the golang tests
go-test:
go test -timeout 0 -v -race -cover ./...
rust-test: ## Run the Rust tests
$(CARGO) nextest run --workspace
test: ## Run the server tests and produce a coverage report (locally)
$(UV) run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
$(UV) run coverage html
$(UV) run coverage report
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
uv run coverage html
uv run coverage report
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
$(UV) run black $(PY_SOURCES)
$(UV) run ruff check --fix $(PY_SOURCES)
$(CARGO) +nightly fmt --all -- --config-path .cargo/rustfmt.toml
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
uv run black $(PY_SOURCES)
uv run ruff check --fix $(PY_SOURCES)
lint-spellcheck: ## Reports spelling errors.
npm run lint:spellcheck
lint-codespell: ## Reports spelling errors.
uv run codespell -w
lint: ci-lint-bandit ci-lint-mypy ci-lint-cargo-deny ci-lint-cargo-machete ## Lint the python and golang sources
lint: ## Lint the python and golang sources
uv run bandit -c pyproject.toml -r $(PY_SOURCES)
golangci-lint run -v
core-install:
ifdef ($(BREW_EXISTS))
ifneq ($(LIBXML2_EXISTS),)
# Clear cache to ensure fresh compilation
$(UV) cache clean
uv cache clean
# Force compilation from source for lxml and xmlsec with correct environment
$(UV) sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
else
$(UV) sync --frozen
uv sync --frozen
endif
migrate: ## Run the Authentik Django server's migrations
$(UV) run python -m lifecycle.migrate
uv run python -m lifecycle.migrate
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn:
cd lifecycle/aws && npm i && $(UV) run npm run aws-cfn
cd lifecycle/aws && npm i && uv run npm run aws-cfn
run-server: ## Run the main authentik server process
$(UV) run ak server
uv run ak server
run-worker: ## Run the main authentik worker process
$(UV) run ak worker
uv run ak worker
core-i18n-extract:
$(UV) run ak makemessages \
uv run ak makemessages \
--add-location file \
--no-obsolete \
--ignore web \
@@ -130,17 +109,11 @@ core-i18n-extract:
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
dev-drop-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
$(eval pg_host := $(shell $(UV) run python -m authentik.lib.config postgresql.host 2>/dev/null))
$(eval pg_name := $(shell $(UV) run python -m authentik.lib.config postgresql.name 2>/dev/null))
dropdb -U ${pg_user} -h ${pg_host} ${pg_name} || true
# Also remove the test-db if it exists
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
dev-create-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
$(eval pg_host := $(shell $(UV) run python -m authentik.lib.config postgresql.host 2>/dev/null))
$(eval pg_name := $(shell $(UV) run python -m authentik.lib.config postgresql.name 2>/dev/null))
createdb -U ${pg_user} -h ${pg_host} ${pg_name}
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
@@ -153,11 +126,11 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
ifndef version
$(error Usage: make bump version=20xx.xx.xx )
endif
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' ${PWD}/pyproject.toml
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' ${PWD}/authentik/__init__.py
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' pyproject.toml
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
$(MAKE) gen-build gen-compose aws-cfn
$(SED_INPLACE) "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/package-lock.json ${PWD}/web/package.json ${PWD}/web/package-lock.json
npm version --no-git-tag-version --allow-same-version $(version)
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
echo -n $(version) > ${PWD}/internal/constants/VERSION
#########################
@@ -168,27 +141,17 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
$(UV) run ak build_schema
uv run ak build_schema
gen-compose:
$(UV) run scripts/generate_compose.py
uv run scripts/generate_compose.py
gen-changelog: ## (Release) generate the changelog based from the commits since the last version
# These are best-effort guesses based on commit messages
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
$(eval current_commit := $(shell git rev-parse HEAD))
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${current_commit} > merged_to_current
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${last_version} > merged_to_last
grep -Eo 'cherry-pick (#\d+)' merged_to_last | cut -d ' ' -f 2 | sed 's/.*/(&)$$/' > cherry_picked_to_last
grep -vf cherry_picked_to_last merged_to_current | sort > changelog.md
rm merged_to_current
rm merged_to_last
rm cherry_picked_to_last
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
npx prettier --write changelog.md
gen-diff: ## (Release) generate the changelog diff between the current schema and the last version
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
git show ${last_version}:schema.yml > schema-old.yml
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
@@ -228,19 +191,28 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
mkdir -p ${PWD}/${GEN_API_PY}
ifeq ($(wildcard ${PWD}/${GEN_API_PY}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
else
cd ${PWD}/${GEN_API_PY} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
gen-client-go: ## Build and install the authentik API for Golang
mkdir -p ${PWD}/${GEN_API_GO}
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
else
cd ${PWD}/${GEN_API_GO} && git reset --hard
cd ${PWD}/${GEN_API_GO} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
make -C ${PWD}/${GEN_API_GO} build
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
gen-dev-config: ## Generate a local development config file
$(UV) run scripts/generate_config.py
uv run scripts/generate_config.py
gen: gen-build gen-client-ts
@@ -291,7 +263,7 @@ docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Au
docs-install:
npm ci --prefix website
docs-lint-fix: lint-spellcheck
docs-lint-fix: lint-codespell
npm run --prefix website prettier
docs-build:
@@ -336,41 +308,28 @@ test-docker:
# which makes the YAML File a lot smaller
ci--meta-debug:
$(UV) run python -V || echo "No python installed"
$(CARGO) --version || echo "No rust installed"
node --version || echo "No node installed"
python -V
node --version
ci-lint-mypy: ci--meta-debug
$(UV) run mypy --strict $(PY_SOURCES)
ci-mypy: ci--meta-debug
uv run mypy --strict $(PY_SOURCES)
ci-lint-black: ci--meta-debug
$(UV) run black --check $(PY_SOURCES)
ci-black: ci--meta-debug
uv run black --check $(PY_SOURCES)
ci-lint-ruff: ci--meta-debug
$(UV) run ruff check $(PY_SOURCES)
ci-ruff: ci--meta-debug
uv run ruff check $(PY_SOURCES)
ci-lint-spellcheck: ci--meta-debug
npm run lint:spellcheck
ci-codespell: ci--meta-debug
uv run codespell -s
ci-lint-bandit: ci--meta-debug
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii
ci-bandit: ci--meta-debug
uv run bandit -r $(PY_SOURCES)
ci-lint-pending-migrations: ci--meta-debug
$(UV) run ak makemigrations --check
ci-lint-cargo-deny: ci--meta-debug
$(CARGO) deny --locked --workspace check --config .cargo/deny.toml
ci-lint-cargo-machete: ci--meta-debug
$(CARGO) machete
ci-lint-rustfmt: ci--meta-debug
$(CARGO) +nightly fmt --all --check -- --config-path .cargo/rustfmt.toml
ci-lint-clippy: ci--meta-debug
$(CARGO) clippy --workspace -- -D warnings
ci-pending-migrations: ci--meta-debug
uv run ak makemigrations --check
ci-test: ci--meta-debug
$(UV) run coverage run manage.py test --keepdb authentik
$(UV) run coverage report
$(UV) run coverage xml
uv run coverage run manage.py test --keepdb authentik
uv run coverage report
uv run coverage xml

View File

@@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| ---------- | ---------- |
| 2025.12.x | ✅ |
| 2026.2.x | ✅ |
| 2025.8.x | ✅ |
| 2025.10.x | ✅ |
## Reporting a Vulnerability

View File

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

View File

@@ -18,6 +18,7 @@ from rest_framework.views import APIView
from authentik import authentik_full_version
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.license import LicenseKey
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
@@ -25,15 +26,6 @@ from authentik.outposts.models import Outpost
from authentik.rbac.permissions import HasPermission
def fips_enabled():
try:
from authentik.enterprise.license import LicenseKey
return backend._fips_enabled if LicenseKey.get_total().status().is_valid else None
except ModuleNotFoundError:
return None
class RuntimeDict(TypedDict):
"""Runtime information"""
@@ -88,7 +80,9 @@ class SystemInfoSerializer(PassiveSerializer):
"architecture": platform.machine(),
"authentik_version": authentik_full_version(),
"environment": get_env(),
"openssl_fips_enabled": fips_enabled(),
"openssl_fips_enabled": (
backend._fips_enabled if LicenseKey.get_total().status().is_valid else None
),
"openssl_version": OPENSSL_VERSION,
"platform": platform.platform(),
"python_version": python_version,

View File

@@ -1,3 +1,5 @@
import mimetypes
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema
@@ -10,14 +12,13 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.backends.base import get_content_type
from authentik.admin.files.fields import FileField as AkFileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileApiUsage
from authentik.admin.files.validation import validate_upload_file_name
from authentik.api.validation import validate
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction
from authentik.lib.utils.reflection import get_apps
from authentik.rbac.permissions import HasPermission
@@ -25,6 +26,11 @@ from authentik.rbac.permissions import HasPermission
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
def get_mime_from_filename(filename: str) -> str:
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
class FileView(APIView):
pagination_class = None
parser_classes = [MultiPartParser]
@@ -47,7 +53,6 @@ class FileView(APIView):
name = CharField()
mime_type = CharField()
url = CharField()
themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
@extend_schema(
parameters=[FileListParameters],
@@ -75,9 +80,8 @@ class FileView(APIView):
FileView.FileListSerializer(
data={
"name": file,
"url": manager.file_url(file, request),
"mime_type": get_content_type(file),
"themed_urls": manager.themed_urls(file, request),
"url": manager.file_url(file),
"mime_type": get_mime_from_filename(file),
}
)
for file in files
@@ -146,7 +150,7 @@ class FileView(APIView):
"pk": name,
"name": name,
"usage": usage.value,
"mime_type": get_content_type(name),
"mime_type": get_mime_from_filename(name),
},
).from_http(request)

View File

@@ -1,4 +1,3 @@
import mimetypes
from collections.abc import Callable, Generator, Iterator
from typing import cast
@@ -11,32 +10,6 @@ from authentik.admin.files.usage import FileUsage
CACHE_PREFIX = "goauthentik.io/admin/files"
LOGGER = get_logger()
# Theme variable placeholder for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def get_content_type(name: str) -> str:
"""Get MIME type for a file based on its extension."""
content_type, _ = mimetypes.guess_type(name)
return content_type or "application/octet-stream"
def get_valid_themes() -> list[str]:
"""Get valid themes that can be substituted for %(theme)s."""
from authentik.brands.api import Themes
return [t.value for t in Themes if t != Themes.AUTOMATIC]
def has_theme_variable(name: str) -> bool:
"""Check if filename contains %(theme)s variable."""
return THEME_VARIABLE in name
def substitute_theme(name: str, theme: str) -> str:
"""Replace %(theme)s with the given theme."""
return name.replace(THEME_VARIABLE, theme)
class Backend:
"""
@@ -94,7 +67,7 @@ class Backend:
Args:
file_path: Relative file path
request: Optional Django HttpRequest for fully qualified URL building
request: Optional Django HttpRequest for fully qualifed URL building
use_cache: whether to retrieve the URL from cache
Returns:
@@ -102,29 +75,6 @@ class Backend:
"""
raise NotImplementedError
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Args:
name: File path potentially containing %(theme)s
request: Optional Django HttpRequest for URL building
Returns:
Dict mapping theme to URL if %(theme)s present, None otherwise
"""
if not has_theme_variable(name):
return None
return {
theme: self.file_url(substitute_theme(name, theme), request, use_cache=True)
for theme in get_valid_themes()
}
class ManageableBackend(Backend):
"""

View File

@@ -45,13 +45,8 @@ class FileBackend(ManageableBackend):
@property
def manageable(self) -> bool:
# Check _base_dir (the mount point, e.g. /data) rather than base_path
# (which includes usage/schema subdirs, e.g. /data/media/public).
# The subdirectories are created on first file write via mkdir(parents=True)
# in save_file(), so requiring them to exist beforehand would prevent
# file creation on fresh installs.
return (
self._base_dir.exists()
self.base_path.exists()
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
or (settings.DEBUG or settings.TEST)
)

View File

@@ -46,25 +46,3 @@ class PassthroughBackend(Backend):
) -> str:
"""Return the URL as-is for passthrough files."""
return name
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""Support themed URLs for external URLs with %(theme)s placeholder.
If the external URL contains %(theme)s, substitute it for each theme.
We can't verify that themed variants exist at the external location,
but we trust the user to provide valid URLs.
"""
from authentik.admin.files.backends.base import (
get_valid_themes,
has_theme_variable,
substitute_theme,
)
if not has_theme_variable(name):
return None
return {theme: substitute_theme(name, theme) for theme in get_valid_themes()}

View File

@@ -9,7 +9,7 @@ from botocore.exceptions import ClientError
from django.db import connection
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
@@ -100,25 +100,13 @@ class S3Backend(ManageableBackend):
f"storage.{self.usage.value}.{self.name}.addressing_style",
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
)
signature_version = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.signature_version",
CONFIG.get(f"storage.{self.name}.signature_version", "s3v4"),
)
# Keep signature_version pass-through and let boto3/botocore handle it.
# In boto3's S3 configuration docs, `s3v4` (default) and deprecated `s3`
# are the documented values:
# https://github.com/boto/boto3/blob/791a3e8f36d83664a47b4281a0586b3546cef3ec/docs/source/guide/configuration.rst?plain=1#L398-L407
# Botocore also supports additional signer names, so we intentionally do
# not enforce a restricted allowlist here.
return self.session.client(
"s3",
endpoint_url=endpoint_url,
use_ssl=use_ssl,
region_name=region_name,
config=Config(
signature_version=signature_version, s3={"addressing_style": addressing_style}
),
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
)
@property
@@ -185,22 +173,7 @@ class S3Backend(ManageableBackend):
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
path = parsed.path
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom_domain must
# include the bucket name (per docs), strip it from the path to avoid
# duplication. See: https://github.com/goauthentik/authentik/issues/19521
# Check with trailing slash to ensure exact bucket name match
if path.startswith(f"/{self.bucket_name}/"):
path = path.removeprefix(f"/{self.bucket_name}")
# Normalize to avoid double slashes
custom_domain = custom_domain.rstrip("/")
if not path.startswith("/"):
path = f"/{path}"
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
return url
@@ -216,7 +189,6 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
ContentType=get_content_type(name),
)
@contextmanager
@@ -232,7 +204,6 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
"ContentType": get_content_type(name),
},
)

View File

@@ -165,31 +165,3 @@ class TestFileBackend(FileTestFileBackendMixin, TestCase):
def test_file_exists_false(self):
"""Test file_exists returns False for nonexistent file"""
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
file_name = "logo.png"
result = self.backend.themed_urls(file_name)
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
file_name = "logo-%(theme)s.png"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs contain the substituted theme
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
file_name = "%(theme)s/logo-%(theme)s.svg"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])

View File

@@ -1,6 +1,5 @@
from unittest import skipUnless
from botocore.exceptions import UnsupportedSignatureVersionError
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
@@ -82,27 +81,6 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
self.assertIn("X-Amz-Signature=", url)
self.assertIn("test.png", url)
def test_client_signature_version_default_v4(self):
"""Test S3 client defaults to v4 signature when not configured."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")
@CONFIG.patch("storage.s3.signature_version", "s3")
def test_client_signature_version_global_override(self):
"""Test S3 client respects globally configured signature version."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
@CONFIG.patch("storage.s3.signature_version", "s3v4")
@CONFIG.patch("storage.media.s3.signature_version", "s3")
def test_client_signature_version_media_override(self):
"""Test usage-specific signature version takes precedence over global."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
@CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
def test_client_signature_version_unsupported(self):
"""Test unsupported signature version raises botocore error."""
with self.assertRaises(UnsupportedSignatureVersionError):
self.media_s3_backend.file_url("test.png", use_cache=False)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""
@@ -132,106 +110,3 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3Backend with REPORTS usage"""
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
@CONFIG.patch("storage.s3.secure_urls", True)
@CONFIG.patch("storage.s3.addressing_style", "path")
def test_file_url_custom_domain_with_bucket_no_duplicate(self):
"""Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
Regression test for https://github.com/goauthentik/authentik/issues/19521
When using:
- Path-style addressing (bucket name goes in URL path, not subdomain)
- Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
The bucket name should NOT appear twice in the final URL.
Example of the bug:
- custom_domain = "s3.example.com/authentik-media"
- boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
- Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
"""
bucket_name = self.media_s3_bucket_name
# Custom domain includes the bucket name
custom_domain = f"localhost:8020/{bucket_name}"
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
# The bucket name should appear exactly once in the URL path, not twice
bucket_occurrences = url.count(bucket_name)
self.assertEqual(
bucket_occurrences,
1,
f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
f"URL: {url}",
)
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
result = self.media_s3_backend.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of presigned URLs for each theme"""
result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs are valid presigned URLs with correct file paths
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
self.assertIn("X-Amz-Signature=", result["light"])
self.assertIn("X-Amz-Signature=", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])
def test_save_file_sets_content_type_svg(self):
"""Test save_file sets correct ContentType for SVG files"""
self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.svg",
)
self.assertEqual(response["ContentType"], "image/svg+xml")
def test_save_file_sets_content_type_png(self):
"""Test save_file sets correct ContentType for PNG files"""
self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
)
self.assertEqual(response["ContentType"], "image/png")
def test_save_file_stream_sets_content_type(self):
"""Test save_file_stream sets correct ContentType"""
with self.media_s3_backend.save_file_stream("test.css") as f:
f.write(b"body { color: red; }")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.css",
)
self.assertEqual(response["ContentType"], "text/css")
def test_save_file_unknown_extension_octet_stream(self):
"""Test save_file sets octet-stream for unknown extensions"""
self.media_s3_backend.save_file("test.unknownext123", b"data")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.unknownext123",
)
self.assertEqual(response["ContentType"], "application/octet-stream")

View File

@@ -88,28 +88,6 @@ class FileManager:
LOGGER.warning(f"Could not find file backend for file: {name}")
return ""
def themed_urls(
self,
name: str | None,
request: HttpRequest | Request | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Returns dict mapping theme to URL if %(theme)s present, None otherwise.
"""
if not name:
return None
if isinstance(request, Request):
request = request._request
for backend in self.backends:
if backend.supports_file(name):
return backend.themed_urls(name, request)
return None
def _check_manageable(self) -> None:
if not self.manageable:
raise ImproperlyConfigured("No file management backend configured.")

View File

@@ -5,6 +5,7 @@ from io import BytesIO
from django.test import TestCase
from django.urls import reverse
from authentik.admin.files.api import get_mime_from_filename
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
@@ -93,9 +94,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -129,9 +129,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -201,64 +200,30 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("field is required", str(response.data))
def test_list_files_includes_themed_urls_none(self):
"""Test listing files includes themed_urls as None for non-themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-no-theme.png"
manager.save_file(file_name, b"test content")
response = self.client.get(
reverse("authentik_api:files", query={"search": file_name, "manageableOnly": "true"})
)
class TestGetMimeFromFilename(TestCase):
"""Test get_mime_from_filename function"""
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsNone(file_entry["themed_urls"])
def test_image_png(self):
"""Test PNG image MIME type"""
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
manager.delete_file(file_name)
def test_image_jpeg(self):
"""Test JPEG image MIME type"""
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
def test_list_files_includes_themed_urls_dict(self):
"""Test listing files includes themed_urls as dict for themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "logo-%(theme)s.svg"
manager.save_file("logo-light.svg", b"<svg>light</svg>")
manager.save_file("logo-dark.svg", b"<svg>dark</svg>")
manager.save_file(file_name, b"<svg>placeholder</svg>")
def test_image_svg(self):
"""Test SVG image MIME type"""
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
response = self.client.get(
reverse("authentik_api:files", query={"search": "%(theme)s", "manageableOnly": "true"})
)
def test_text_plain(self):
"""Test text file MIME type"""
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsInstance(file_entry["themed_urls"], dict)
self.assertIn("light", file_entry["themed_urls"])
self.assertIn("dark", file_entry["themed_urls"])
def test_unknown_extension(self):
"""Test unknown extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
manager.delete_file(file_name)
manager.delete_file("logo-light.svg")
manager.delete_file("logo-dark.svg")
def test_upload_file_with_theme_variable(self):
"""Test uploading file with %(theme)s in name"""
manager = FileManager(FileUsage.MEDIA)
file_name = "brand-logo-%(theme)s.svg"
file_content = b"<svg></svg>"
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(manager.file_exists(file_name))
manager.delete_file(file_name)
def test_no_extension(self):
"""Test no extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")

View File

@@ -1,7 +1,6 @@
"""Test file service layer"""
from unittest import skipUnless
from urllib.parse import urlparse
from django.http import HttpRequest
from django.test import TestCase
@@ -105,71 +104,3 @@ class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
# S3 URLs should be returned as-is (already absolute)
self.assertTrue(result.startswith("http://s3.test:8080/test"))
class TestThemedUrls(FileTestFileBackendMixin, TestCase):
"""Test FileManager.themed_urls method"""
def test_themed_urls_none_path(self):
"""Test themed_urls returns None for None path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls(None)
self.assertIsNone(result)
def test_themed_urls_empty_path(self):
"""Test themed_urls returns None for empty path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("")
self.assertIsNone(result)
def test_themed_urls_no_theme_variable(self):
"""Test themed_urls returns None when no %(theme)s in path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_with_request(self):
"""Test themed_urls builds absolute URLs with request"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.svg", mock_request)
self.assertIsInstance(result, dict)
light_url = urlparse(result["light"])
dark_url = urlparse(result["dark"])
self.assertEqual(light_url.scheme, "http")
self.assertEqual(light_url.netloc, "example.com")
self.assertEqual(dark_url.scheme, "http")
self.assertEqual(dark_url.netloc, "example.com")
def test_themed_urls_passthrough_with_theme_variable(self):
"""Test themed_urls returns dict for passthrough URLs with %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs with %(theme)s should return themed URLs
result = manager.themed_urls("https://example.com/logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertEqual(result["light"], "https://example.com/logo-light.png")
self.assertEqual(result["dark"], "https://example.com/logo-dark.png")
def test_themed_urls_passthrough_without_theme_variable(self):
"""Test themed_urls returns None for passthrough URLs without %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs without %(theme)s should return None
result = manager.themed_urls("https://example.com/logo.png")
self.assertIsNone(result)

View File

@@ -62,10 +62,10 @@ class TestSanitizeFilePath(TestCase):
"test@file.png", # @
"test#file.png", # #
"test$file.png", # $
"test%file.png", # % (but %(theme)s is allowed)
"test%file.png", # %
"test&file.png", # &
"test*file.png", # *
"test(file).png", # parentheses (but %(theme)s is allowed)
"test(file).png", # parentheses
"test[file].png", # brackets
"test{file}.png", # braces
]
@@ -108,30 +108,3 @@ class TestSanitizeFilePath(TestCase):
with self.assertRaises(ValidationError):
validate_file_name(path)
def test_sanitize_theme_variable_valid(self):
"""Test sanitizing filename with %(theme)s variable"""
# These should all be valid
validate_file_name("logo-%(theme)s.png")
validate_file_name("brand/logo-%(theme)s.svg")
validate_file_name("images/icon-%(theme)s.png")
validate_file_name("%(theme)s/logo.png")
validate_file_name("brand/%(theme)s/logo.png")
def test_sanitize_theme_variable_multiple(self):
"""Test sanitizing filename with multiple %(theme)s variables"""
validate_file_name("%(theme)s/logo-%(theme)s.png")
def test_sanitize_theme_variable_invalid_format(self):
"""Test that partial or malformed theme variables are rejected"""
invalid_paths = [
"test%(theme.png", # missing )s
"test%theme)s.png", # missing (
"test%(themes).png", # wrong variable name
"test%(THEME)s.png", # wrong case
"test%()s.png", # empty variable name
]
for path in invalid_paths:
with self.assertRaises(ValidationError):
validate_file_name(path)

View File

@@ -4,7 +4,6 @@ from pathlib import PurePosixPath
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from authentik.admin.files.backends.base import THEME_VARIABLE
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
@@ -40,17 +39,12 @@ def validate_upload_file_name(
if not name:
raise ValidationError(_("File name cannot be empty"))
# Allow %(theme)s placeholder for theme-specific files
# Replace with placeholder for validation, then check the result
name_for_validation = name.replace(THEME_VARIABLE, "theme")
# Same regex is used in the frontend as well (with %(theme)s handling)
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
# Same regex is used in the frontend as well
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
raise ValidationError(
_(
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
"and the placeholder %(theme)s for theme-specific files"
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
)
)

View File

@@ -13,10 +13,10 @@ from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.common.oauth.constants import SCOPE_AUTHENTIK_API
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
LOGGER = get_logger()
_tmp = Path(gettempdir())

View File

@@ -71,7 +71,7 @@ def postprocess_schema_responses(
def postprocess_schema_query_params(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Optimize pagination parameters, instead of redeclaring parameters for each endpoint
"""Optimise pagination parameters, instead of redeclaring parameters for each endpoint
declare them globally and refer to them"""
LOGGER.debug("Deduplicating query parameters")
for path in result["paths"].values():

View File

@@ -11,12 +11,12 @@ from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import IPCUser, TokenAuthentication
from authentik.blueprints.tests import reconcile_app
from authentik.common.oauth.constants import SCOPE_AUTHENTIK_API
from authentik.core.models import Token, TokenIntents, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider

View File

@@ -1,8 +1,6 @@
"""Schema generation tests"""
from pathlib import Path
from tempfile import gettempdir
from uuid import uuid4
from django.core.management import call_command
from django.urls import reverse
@@ -31,14 +29,15 @@ class TestSchemaGeneration(APITestCase):
def test_build_schema(self):
"""Test schema build command"""
tmp = Path(gettempdir())
blueprint_file = tmp / f"{str(uuid4())}.json"
api_file = tmp / f"{str(uuid4())}.yml"
blueprint_file = Path("blueprints/schema.json")
api_file = Path("schema.yml")
blueprint_file.unlink()
api_file.unlink()
with (
CONFIG.patch("debug", True),
CONFIG.patch("tenants.enabled", True),
CONFIG.patch("outposts.disable_embedded_outpost", True),
):
call_command("build_schema", blueprint_file=blueprint_file, api_file=api_file)
call_command("build_schema")
self.assertTrue(blueprint_file.exists())
self.assertTrue(api_file.exists())

View File

@@ -18,7 +18,7 @@ entries:
name: foo
title: foo
permissions:
- permission: authentik_flows.view_flow
- permission: view_flow
user: !KeyOf user
- permission: authentik_flows.view_flow
- permission: view_flow
role: !KeyOf role

View File

@@ -9,7 +9,7 @@ from functools import reduce
from json import JSONDecodeError, loads
from operator import ixor
from os import getenv
from typing import Any, Literal
from typing import Any, Literal, Union
from uuid import UUID
from deepmerge import always_merger
@@ -43,6 +43,8 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]:
continue
if _field.read_only:
data.pop(field_name, None)
if _field.get_initial() == data.get(field_name, None):
data.pop(field_name, None)
if field_name.endswith("_set"):
data.pop(field_name, None)
return data
@@ -68,17 +70,19 @@ class BlueprintEntryDesiredState(Enum):
class BlueprintEntryPermission:
"""Describe object-level permissions"""
permission: str | YAMLTag
user: int | YAMLTag | None = field(default=None)
role: str | YAMLTag | None = field(default=None)
permission: Union[str, "YAMLTag"]
user: Union[int, "YAMLTag", None] = field(default=None)
role: Union[str, "YAMLTag", None] = field(default=None)
@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
model: str | YAMLTag
state: BlueprintEntryDesiredState | YAMLTag = field(default=BlueprintEntryDesiredState.PRESENT)
model: Union[str, "YAMLTag"]
state: Union[BlueprintEntryDesiredState, "YAMLTag"] = field(
default=BlueprintEntryDesiredState.PRESENT
)
conditions: list[Any] = field(default_factory=list)
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: dict[str, Any] | None = field(default_factory=dict)
@@ -92,7 +96,7 @@ class BlueprintEntry:
self.__tag_contexts: list[YAMLTagContext] = []
@staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> BlueprintEntry:
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
"""Convert a SerializerModel instance to a blueprint Entry"""
identifiers = {
"pk": model.pk,
@@ -110,8 +114,8 @@ class BlueprintEntry:
def get_tag_context(
self,
depth: int = 0,
context_tag_type: type[YAMLTagContext] | tuple[YAMLTagContext, ...] | None = None,
) -> YAMLTagContext:
context_tag_type: type["YAMLTagContext"] | tuple["YAMLTagContext", ...] | None = None,
) -> "YAMLTagContext":
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
if depth < 0:
raise ValueError("depth must be a positive number or zero")
@@ -126,7 +130,7 @@ class BlueprintEntry:
except IndexError as exc:
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") from exc
def tag_resolver(self, value: Any, blueprint: Blueprint) -> Any:
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
"""Check if we have any special tags that need handling"""
val = copy(value)
@@ -148,23 +152,23 @@ class BlueprintEntry:
return val
def get_attrs(self, blueprint: Blueprint) -> dict[str, Any]:
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.attrs, blueprint)
def get_identifiers(self, blueprint: Blueprint) -> dict[str, Any]:
def get_identifiers(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.identifiers, blueprint)
def get_state(self, blueprint: Blueprint) -> BlueprintEntryDesiredState:
def get_state(self, blueprint: "Blueprint") -> BlueprintEntryDesiredState:
"""Get the blueprint state, with yaml tags resolved if present"""
return BlueprintEntryDesiredState(self.tag_resolver(self.state, blueprint))
def get_model(self, blueprint: Blueprint) -> str:
def get_model(self, blueprint: "Blueprint") -> str:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))
def get_permissions(self, blueprint: Blueprint) -> Generator[BlueprintEntryPermission]:
def get_permissions(self, blueprint: "Blueprint") -> Generator[BlueprintEntryPermission]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(
@@ -173,7 +177,7 @@ class BlueprintEntry:
role=self.tag_resolver(perm.role, blueprint),
)
def check_all_conditions_match(self, blueprint: Blueprint) -> bool:
def check_all_conditions_match(self, blueprint: "Blueprint") -> bool:
"""Check all conditions of this entry match (evaluate to True)"""
return all(self.tag_resolver(self.conditions, blueprint))
@@ -228,7 +232,7 @@ class KeyOf(YAMLTag):
id_from: str
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.id_from = node.value
@@ -254,7 +258,7 @@ class Env(YAMLTag):
key: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -273,7 +277,7 @@ class File(YAMLTag):
path: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -301,7 +305,7 @@ class Context(YAMLTag):
key: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -324,7 +328,7 @@ class ParseJSON(YAMLTag):
raw: str
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.raw = node.value
@@ -341,7 +345,7 @@ class Format(YAMLTag):
format_string: str
args: list[Any]
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.format_string = loader.construct_object(node.value[0])
self.args = []
@@ -368,7 +372,7 @@ class Find(YAMLTag):
model_name: str | YAMLTag
conditions: list[list]
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.model_name = loader.construct_object(node.value[0])
self.conditions = []
@@ -440,7 +444,7 @@ class Condition(YAMLTag):
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
}
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.mode = loader.construct_object(node.value[0])
self.args = []
@@ -474,7 +478,7 @@ class If(YAMLTag):
when_true: Any
when_false: Any
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.condition = loader.construct_object(node.value[0])
if len(node.value) == 1:
@@ -514,7 +518,7 @@ class Enumerate(YAMLTag, YAMLTagContext):
),
}
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.iterable = loader.construct_object(node.value[0])
self.output_body = loader.construct_object(node.value[1])
@@ -580,7 +584,7 @@ class EnumeratedItem(YAMLTag):
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
def __init__(self, _loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, _loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.depth = int(node.value)
@@ -636,7 +640,7 @@ class AtIndex(YAMLTag):
attribute: int | str | YAMLTag
default: Any | UNSET
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.obj = loader.construct_object(node.value[0])
self.attribute = loader.construct_object(node.value[1])
@@ -753,7 +757,7 @@ class EntryInvalidError(SentryIgnoredException):
@staticmethod
def from_entry(
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
) -> EntryInvalidError:
) -> "EntryInvalidError":
"""Create EntryInvalidError with the context of an entry"""
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
if isinstance(msg_or_exc, ValidationError):

View File

@@ -15,7 +15,7 @@ from django.db.models import Model
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from guardian.models import RoleObjectPermission
from guardian.models import RoleObjectPermission, UserObjectPermission
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@@ -41,6 +41,7 @@ from authentik.core.models import (
UserSourceConnection,
)
from authentik.endpoints.models import Connector
from authentik.enterprise.license import LicenseKey
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.utils import cleanse_dict
from authentik.flows.models import Stage
@@ -70,6 +71,7 @@ def excluded_models() -> list[type[Model]]:
ContentType,
Permission,
RoleObjectPermission,
UserObjectPermission,
# Base classes
Provider,
Source,
@@ -139,22 +141,13 @@ class Importer:
def default_context(self):
"""Default context"""
context = {
return {
"goauthentik.io/enterprise/licensed": LicenseKey.get_total().status().is_valid,
"goauthentik.io/rbac/models": rbac_models(),
"goauthentik.io/enterprise/licensed": False,
}
try:
from authentik.enterprise.license import LicenseKey
context["goauthentik.io/enterprise/licensed"] = (
LicenseKey.get_total().status().is_valid,
)
except ModuleNotFoundError:
pass
return context
@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> Importer:
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
"""Parse YAML string and create blueprint importer from it"""
import_dict = load(yaml_input, BlueprintLoader)
try:
@@ -272,7 +265,7 @@ class Importer:
and entry.state != BlueprintEntryDesiredState.MUST_CREATED
):
self.logger.debug(
"Initialize serializer with instance",
"Initialise serializer with instance",
model=model,
instance=model_instance,
pk=model_instance.pk,
@@ -290,7 +283,7 @@ class Importer:
)
else:
self.logger.debug(
"Initialized new serializer instance",
"Initialised new serializer instance",
model=model,
**cleanse_dict(updated_identifiers),
)

View File

@@ -23,7 +23,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
# We cannot override `instance` as that will confuse rest_framework
# and make it attempt to update the instance
blueprint_instance: BlueprintInstance
blueprint_instance: "BlueprintInstance"
def validate(self, attrs):
from authentik.blueprints.models import BlueprintInstance

View File

@@ -6,12 +6,7 @@ from django.db import models
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
CharField,
ChoiceField,
ListField,
SerializerMethodField,
)
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@@ -21,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.rbac.filters import SecretKeyFilter
from authentik.tenants.api.settings import FlagJSONField
from authentik.tenants.flags import Flag
@@ -95,9 +90,7 @@ class CurrentBrandSerializer(PassiveSerializer):
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField(source="branding_logo_url")
branding_logo_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_favicon = CharField(source="branding_favicon_url")
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_custom_css = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),
@@ -124,8 +117,10 @@ class CurrentBrandSerializer(PassiveSerializer):
@extend_schema_field(field=FlagJSONField)
def get_flags(self, _):
values = {}
for flag in Flag.available(visibility="public"):
values[flag().key] = flag.get()
for flag in Flag.available():
_flag = flag()
if _flag.visibility == "public":
values[_flag.key] = _flag.get()
return values

View File

@@ -89,26 +89,14 @@ class Brand(SerializerModel):
"""Get branding_logo URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
def branding_logo_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_logo if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_logo)
def branding_favicon_url(self) -> str:
"""Get branding_favicon URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
def branding_favicon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_favicon if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_favicon)
def branding_default_flow_background_url(self) -> str:
"""Get branding_default_flow_background URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
def branding_default_flow_background_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_default_flow_background if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_default_flow_background)
@property
def serializer(self) -> type[Serializer]:
from authentik.brands.api import BrandSerializer

View File

@@ -6,6 +6,7 @@ from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
@@ -21,8 +22,10 @@ class TestBrands(APITestCase):
def setUp(self):
super().setUp()
self.default_flags = {}
for flag in Flag.available(visibility="public"):
self.default_flags[flag().key] = flag.get()
for flag in Flag.available():
_flag = flag()
if _flag.visibility == "public":
self.default_flags[_flag.key] = _flag.get()
Brand.objects.all().delete()
def test_current_brand(self):
@@ -32,14 +35,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -54,14 +55,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -73,14 +72,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "fallback",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -97,14 +94,12 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -122,14 +117,12 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -140,14 +133,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -163,14 +154,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-strong",
"branding_custom_css": "",
"matched_domain": "foo.bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -186,14 +175,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-weak",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -269,14 +256,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "https://goauthentik.io/img/icon.png",
"branding_logo_themed_urls": None,
"branding_favicon": "https://goauthentik.io/img/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},

View File

@@ -3,7 +3,7 @@
from typing import Any
from django.db.models import Case, F, IntegerField, Q, Value, When
from django.db.models.functions import Concat, Length
from django.db.models.functions import Length
from django.http.request import HttpRequest
from django.utils.html import _json_script_escapes
from django.utils.safestring import mark_safe
@@ -26,8 +26,7 @@ def get_brand_for_request(request: HttpRequest) -> Brand:
domain_length=Length("domain"),
match_priority=Case(
When(
condition=Q(host_domain__iexact=F("domain"))
| Q(host_domain__iendswith=Concat(Value("."), F("domain"))),
condition=Q(host_domain__iendswith=F("domain")),
then=F("domain_length"),
),
default=Value(-1),

View File

@@ -24,8 +24,7 @@ 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, ThemedUrlsSerializer
from authentik.core.apps import AppAccessWithoutBindings
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.policies.api.exec import PolicyTestResultSerializer
@@ -54,9 +53,6 @@ class ApplicationSerializer(ModelSerializer):
)
meta_icon_url = ReadOnlyField(source="get_meta_icon")
meta_icon_themed_urls = ThemedUrlsSerializer(
source="get_meta_icon_themed_urls", read_only=True, allow_null=True
)
def get_launch_url(self, app: Application) -> str | None:
"""Allow formatting of launch URL"""
@@ -67,7 +63,7 @@ class ApplicationSerializer(ModelSerializer):
user = self.context["request"].user
# Cache serialized user data to avoid N+1 when formatting launch URLs
# for multiple applications. UserSerializer accesses user.groups which
# 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:
@@ -106,7 +102,6 @@ class ApplicationSerializer(ModelSerializer):
"meta_launch_url",
"meta_icon",
"meta_icon_url",
"meta_icon_themed_urls",
"meta_description",
"meta_publisher",
"policy_engine_mode",
@@ -155,16 +150,15 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
return queryset
def _get_allowed_applications(
self, paginated_apps: Iterator[Application], user: User | None = None
self, pagined_apps: Iterator[Application], user: User | None = None
) -> list[Application]:
applications = []
request = self.request._request
if user:
request = copy(request)
request.user = user
for application in paginated_apps:
for application in pagined_apps:
engine = PolicyEngine(application, request.user, request)
engine.empty_result = AppAccessWithoutBindings.get()
engine.build()
if engine.passing:
applications.append(application)
@@ -222,7 +216,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if not for_user:
raise ValidationError({"for_user": "User not found"})
engine = PolicyEngine(application, for_user, request)
engine.empty_result = AppAccessWithoutBindings.get()
engine.use_cache = False
with capture_logs() as logs:
engine.build()

View File

@@ -2,31 +2,18 @@
from typing import TypedDict
from drf_spectacular.utils import (
extend_schema,
inline_serializer,
)
from rest_framework import mixins, serializers
from rest_framework.decorators import action
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
CharField,
DateTimeField,
IPAddressField,
ListField,
)
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
from authentik.rbac.decorators import permission_required
class UserAgentDeviceDict(TypedDict):
@@ -65,14 +52,6 @@ class UserAgentDict(TypedDict):
string: str
class BulkDeleteSessionSerializer(PassiveSerializer):
"""Serializer for bulk deleting authenticated sessions by user"""
user_pks = ListField(
child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for"
)
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
@@ -136,22 +115,3 @@ class AuthenticatedSessionViewSet(
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
ordering = ["user__username"]
owner_field = "user"
@permission_required("authentik_core.delete_authenticatedsession")
@extend_schema(
parameters=[BulkDeleteSessionSerializer],
responses={
200: inline_serializer(
"BulkDeleteSessionResponse",
{"deleted": serializers.IntegerField()},
),
},
)
@validate(BulkDeleteSessionSerializer, location="query")
@action(detail=False, methods=["DELETE"], pagination_class=None, filter_backends=[])
def bulk_delete(self, request: Request, *, query: BulkDeleteSessionSerializer) -> Response:
"""Bulk revoke all sessions for multiple users"""
user_pks = query.validated_data.get("user_pks", [])
deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_pks).delete()
return Response({"deleted": deleted_count}, status=200)

View File

@@ -16,15 +16,11 @@ from rest_framework.viewsets import ViewSet
from authentik.api.validation import validate
from authentik.core.api.users import ParamUserSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
try:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
except ModuleNotFoundError:
EndpointDevice = None
class DeviceSerializer(MetaNameSerializer):
"""Serializer for authenticator devices"""
@@ -47,7 +43,7 @@ class DeviceSerializer(MetaNameSerializer):
"""Get extra description"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.description if instance.device_type else None
if EndpointDevice and isinstance(instance, EndpointDevice):
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return None
@@ -55,7 +51,7 @@ class DeviceSerializer(MetaNameSerializer):
"""Get external Device ID"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.aaguid if instance.device_type else None
if EndpointDevice and isinstance(instance, EndpointDevice):
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return None

View File

@@ -85,7 +85,6 @@ class GroupSerializer(ModelSerializer):
source="roles",
required=False,
)
inherited_roles_obj = SerializerMethodField(allow_null=True)
num_pk = IntegerField(read_only=True)
@property
@@ -109,13 +108,6 @@ class GroupSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_parents", "false")).lower() == "true"
@property
def _should_include_inherited_roles(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true"
@extend_schema_field(PartialUserSerializer(many=True))
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
if not self._should_include_users:
@@ -134,15 +126,6 @@ class GroupSerializer(ModelSerializer):
return None
return RelatedGroupSerializer(instance.parents, many=True).data
@extend_schema_field(RoleSerializer(many=True))
def get_inherited_roles_obj(self, instance: Group) -> list | None:
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
if not self._should_include_inherited_roles:
return None
direct_role_pks = instance.roles.values_list("pk", flat=True)
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
return RoleSerializer(inherited_roles, many=True).data
def validate_is_superuser(self, superuser: bool):
"""Ensure that the user creating this group has permissions to set the superuser flag"""
request: Request = self.context.get("request", None)
@@ -184,7 +167,6 @@ class GroupSerializer(ModelSerializer):
"attributes",
"roles",
"roles_obj",
"inherited_roles_obj",
"children",
"children_obj",
]
@@ -307,7 +289,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
OpenApiParameter("include_inherited_roles", bool, default=False),
]
)
def list(self, request, *args, **kwargs):
@@ -318,7 +299,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
OpenApiParameter("include_inherited_roles", bool, default=False),
]
)
def retrieve(self, request, *args, **kwargs):

View File

@@ -10,6 +10,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.models import DeprecatedMixin
from authentik.lib.utils.reflection import all_subclasses
@@ -60,25 +61,19 @@ class TypesMixin:
continue
instance = subclass()
try:
type_signature = {
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": instance.component,
"model_name": subclass._meta.model_name,
"icon_url": getattr(instance, "icon_url", None),
"requires_enterprise": False,
"deprecated": isinstance(instance, DeprecatedMixin),
}
try:
from authentik.enterprise.apps import EnterpriseConfig
type_signature["requires_enterprise"] = isinstance(
subclass._meta.app_config, EnterpriseConfig
)
except ModuleNotFoundError:
pass
data.append(type_signature)
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": instance.component,
"model_name": subclass._meta.model_name,
"icon_url": getattr(instance, "icon_url", None),
"requires_enterprise": isinstance(
subclass._meta.app_config, EnterpriseConfig
),
"deprecated": isinstance(instance, DeprecatedMixin),
}
)
except NotImplementedError:
continue
if additional:

View File

@@ -18,14 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
assigned_backchannel_application_slug = ReadOnlyField(
source="backchannel_application.slug", allow_null=True
)
assigned_backchannel_application_name = ReadOnlyField(
source="backchannel_application.name", allow_null=True
)
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
component = SerializerMethodField()

View File

@@ -14,7 +14,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.policies.engine import PolicyEngine
@@ -28,7 +28,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
managed = ReadOnlyField()
component = SerializerMethodField()
icon_url = ReadOnlyField()
icon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
def get_component(self, obj: Source) -> str:
"""Get object component so that we know how to edit the object"""
@@ -58,7 +57,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"user_path_template",
"icon",
"icon_url",
"icon_themed_urls",
]

View File

@@ -75,8 +75,7 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
except ValueError:
pass
expires = attrs.get("expires")
if expires is not None and expires > max_token_lifetime_dt:
if "expires" in attrs and attrs.get("expires") > max_token_lifetime_dt:
raise ValidationError(
{
"expires": (

View File

@@ -30,6 +30,7 @@ from drf_spectacular.utils import (
extend_schema_field,
inline_serializer,
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -41,7 +42,6 @@ from rest_framework.fields import (
IntegerField,
ListField,
SerializerMethodField,
UUIDField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
@@ -72,14 +72,12 @@ from authentik.core.middleware import (
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
USERNAME_MAX_LENGTH,
Group,
Session,
Token,
TokenIntents,
User,
UserTypes,
default_token_duration,
)
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.events.models import Event, EventAction
@@ -89,7 +87,6 @@ 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.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import Role, get_permission_choices
@@ -132,6 +129,7 @@ class UserSerializer(ModelSerializer):
groups = PrimaryKeyRelatedField(
allow_empty=True,
many=True,
source="ak_groups",
queryset=Group.objects.all().order_by("name"),
default=list,
)
@@ -145,7 +143,7 @@ class UserSerializer(ModelSerializer):
roles_obj = SerializerMethodField(allow_null=True)
uid = CharField(read_only=True)
username = CharField(
max_length=USERNAME_MAX_LENGTH,
max_length=150,
validators=[UniqueValidator(queryset=User.objects.all().order_by("username"))],
)
@@ -167,7 +165,7 @@ class UserSerializer(ModelSerializer):
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
if not self._should_include_groups:
return None
return PartialGroupSerializer(instance.groups, many=True).data
return PartialGroupSerializer(instance.ak_groups, many=True).data
@extend_schema_field(RoleSerializer(many=True))
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
@@ -241,14 +239,14 @@ class UserSerializer(ModelSerializer):
and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT
and user_type != UserTypes.INTERNAL_SERVICE_ACCOUNT.value
):
raise ValidationError(_("Can't change internal service account to other user type."))
raise ValidationError("Can't change internal service account to other user type.")
if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value:
raise ValidationError(_("Setting a user to internal service account is not allowed."))
raise ValidationError("Setting a user to internal service account is not allowed.")
return user_type
def validate(self, attrs: dict) -> dict:
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError(_("Can't modify internal service account users"))
raise ValidationError("Can't modify internal service account users")
return super().validate(attrs)
class Meta:
@@ -400,18 +398,6 @@ class UserServiceAccountSerializer(PassiveSerializer):
)
class UserRecoveryLinkSerializer(PassiveSerializer):
"""Payload to create a recovery link"""
token_duration = CharField(required=False)
class UserRecoveryEmailSerializer(UserRecoveryLinkSerializer):
"""Payload to create and email a recovery link"""
email_stage = UUIDField()
class UsersFilter(FilterSet):
"""Filter for users"""
@@ -430,12 +416,7 @@ class UsersFilter(FilterSet):
last_updated = IsoDateTimeFilter(field_name="last_updated")
last_updated__gt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="gt")
last_login__lt = IsoDateTimeFilter(field_name="last_login", lookup_expr="lt")
last_login = IsoDateTimeFilter(field_name="last_login")
last_login__gt = IsoDateTimeFilter(field_name="last_login", lookup_expr="gt")
last_login__isnull = BooleanFilter(field_name="last_login", lookup_expr="isnull")
is_superuser = BooleanFilter(field_name="groups", method="filter_is_superuser")
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
@@ -444,12 +425,12 @@ class UsersFilter(FilterSet):
type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type")
groups_by_name = ModelMultipleChoiceFilter(
field_name="groups__name",
field_name="ak_groups__name",
to_field_name="name",
queryset=Group.objects.all().order_by("name"),
)
groups_by_pk = ModelMultipleChoiceFilter(
field_name="groups",
field_name="ak_groups",
queryset=Group.objects.all().order_by("name"),
)
@@ -465,22 +446,22 @@ class UsersFilter(FilterSet):
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(groups__is_superuser=True).distinct()
return queryset.exclude(groups__is_superuser=True).distinct()
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
value = loads(value)
except ValueError:
raise ValidationError(_("filter: failed to parse JSON")) from None
raise ValidationError(detail="filter: failed to parse JSON") from None
if not isinstance(value, dict):
raise ValidationError(_("filter: value must be key:value mapping"))
raise ValidationError(detail="filter: value must be key:value mapping")
qs = {}
for key, _value in value.items():
qs[f"attributes__{key}"] = _value
try:
__ = len(queryset.filter(**qs))
_ = len(queryset.filter(**qs))
return queryset.filter(**qs)
except ValueError:
return queryset
@@ -492,7 +473,6 @@ class UsersFilter(FilterSet):
"email",
"date_joined",
"last_updated",
"last_login",
"name",
"is_active",
"is_superuser",
@@ -513,7 +493,7 @@ class UserViewSet(
"""User Viewset"""
queryset = User.objects.none()
ordering = ["username", "date_joined", "last_updated", "last_login"]
ordering = ["username", "date_joined", "last_updated"]
serializer_class = UserSerializer
filterset_class = UsersFilter
search_fields = ["email", "name", "uuid", "username"]
@@ -544,7 +524,7 @@ class UserViewSet(
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("groups")
base_qs = base_qs.prefetch_related("ak_groups")
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
return base_qs
@@ -558,16 +538,14 @@ class UserViewSet(
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def _create_recovery_link(
self, token_duration: str | None, for_email=False
) -> tuple[str, Token]:
def _create_recovery_link(self, for_email=False) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request.brand
brand: Brand = self.request._request.brand
# Check that there is a recovery flow, if not return an error
flow = brand.flow_recovery
if not flow:
raise ValidationError({"non_field_errors": _("No recovery flow set.")})
raise ValidationError({"non_field_errors": "No recovery flow set."})
user: User = self.get_object()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
@@ -581,15 +559,11 @@ class UserViewSet(
)
except FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": _("Recovery flow not applicable to user")}
{"non_field_errors": "Recovery flow not applicable to user"}
) from None
_plan = FlowToken.pickle(plan)
if for_email:
_plan = pickle_flow_token_for_email(plan)
expires = default_token_duration()
if token_duration:
timedelta_string_validator(token_duration)
expires = now() + timedelta_from_string(token_duration)
token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset",
defaults={
@@ -597,7 +571,6 @@ class UserViewSet(
"flow": flow,
"_plan": _plan,
"revoke_on_execution": not for_email,
"expires": expires,
},
)
querystring = urlencode({QS_KEY_TOKEN: token.key})
@@ -745,60 +718,60 @@ class UserViewSet(
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserRecoveryLinkSerializer,
responses={
"200": LinkSerializer(many=False),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
@validate(UserRecoveryLinkSerializer)
def recovery(self, request: Request, pk: int, body: UserRecoveryLinkSerializer) -> Response:
def recovery(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their account"""
link, _ = self._create_recovery_link(
token_duration=body.validated_data.get("token_duration")
)
link, _ = self._create_recovery_link()
return Response({"link": link})
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserRecoveryEmailSerializer,
parameters=[
OpenApiParameter(
name="email_stage",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
responses={
"204": OpenApiResponse(description="Successfully sent recover email"),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
@validate(UserRecoveryEmailSerializer)
def recovery_email(
self, request: Request, pk: int, body: UserRecoveryEmailSerializer
) -> Response:
def recovery_email(self, request: Request, pk: int) -> Response:
"""Send an email with a temporary link that a user can use to recover their account"""
email_error_message = _("User does not have an email address set.")
stage_error_message = _("Email stage not found.")
user: User = self.get_object()
if not user.email:
for_user: User = self.get_object()
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
raise ValidationError({"non_field_errors": email_error_message})
if not (stage := EmailStage.objects.filter(pk=body.validated_data["email_stage"]).first()):
LOGGER.debug("Email stage does not exist")
raise ValidationError({"non_field_errors": stage_error_message})
if not request.user.has_perm("authentik_stages_email.view_emailstage", stage):
LOGGER.debug("User has no view access to email stage")
raise ValidationError({"non_field_errors": stage_error_message})
link, token = self._create_recovery_link(
token_duration=body.validated_data.get("token_duration"), for_email=True
)
raise ValidationError({"non_field_errors": "User does not have an email address set."})
link, token = self._create_recovery_link(for_email=True)
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"
).filter(pk=request.query_params.get("email_stage"))
if not stages.exists():
LOGGER.debug("Email stage does not exist/user has no permissions")
raise ValidationError({"non_field_errors": "Email stage does not exist."})
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(
subject=_(stage.subject),
to=[(user.name, user.email)],
template_name=stage.template,
language=user.locale(request),
subject=_(email_stage.subject),
to=[(for_user.name, for_user.email)],
template_name=email_stage.template,
language=for_user.locale(request),
template_context={
"url": link,
"user": user,
"user": for_user,
"expires": token.expires,
},
)
send_mails(stage, message)
send_mails(email_stage, message)
return Response(status=204)
@permission_required("authentik_core.impersonate")

View File

@@ -127,10 +127,3 @@ class LinkSerializer(PassiveSerializer):
"""Returns a single link"""
link = CharField()
class ThemedUrlsSerializer(PassiveSerializer):
"""Themed URLs - maps theme names to URLs for light and dark themes"""
light = CharField(required=False, allow_null=True)
dark = CharField(required=False, allow_null=True)

View File

@@ -1,20 +1,7 @@
"""authentik core app config"""
from django.utils.translation import gettext_lazy as _
from authentik.blueprints.apps import ManagedAppConfig
from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tenants.flags import Flag
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
default = True
visibility = "none"
description = _(
"Configure if applications without any policy/group/user bindings "
"should be accessible to any user."
)
class AuthentikCoreConfig(ManagedAppConfig):

View File

@@ -8,7 +8,7 @@ from uuid import uuid4
from django.contrib.auth import logout
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override
@@ -47,7 +47,7 @@ async def aget_user(request):
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest) -> HttpResponseBadRequest | None:
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
@@ -62,8 +62,7 @@ class AuthenticationMiddleware(MiddlewareMixin):
user = request.user
if user and user.is_authenticated and not user.is_active:
logout(request)
return HttpResponseBadRequest()
return None
raise AssertionError()
class ImpersonateMiddleware:

View File

@@ -16,7 +16,7 @@ def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
for obj in model.objects.using(db_alias).only("is_backchannel"):
obj.is_backchannel = True
obj.save()
except DatabaseError, InternalError, ProgrammingError:
except (DatabaseError, InternalError, ProgrammingError):
# The model might not have been migrated yet/doesn't exist yet
# so we don't need to worry about backporting the data
pass

View File

@@ -1,9 +1,101 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
try:
return pickle.loads(data) # nosec
except Exception:
return {}
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
uuid=old_auth_session.uuid,
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
@@ -113,4 +205,8 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -1,47 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-19 21:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0056_user_roles"),
("authentik_rbac", "0010_remove_role_group_alter_role_name"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="user_permissions",
),
migrations.AlterField(
model_name="group",
name="roles",
field=models.ManyToManyField(
blank=True, related_name="groups", to="authentik_rbac.role"
),
),
migrations.RemoveField(
model_name="user",
name="groups",
),
migrations.RenameField(
model_name="user",
old_name="ak_groups",
new_name="groups",
),
migrations.AlterModelOptions(
name="user",
options={
"permissions": [
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
("preview_user", "Can preview user data sent to providers"),
("view_user_applications", "View applications the user has access to"),
],
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
]

View File

@@ -1,11 +1,9 @@
"""authentik core models"""
import re
import traceback
from datetime import datetime, timedelta
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from typing import Any, Self
from typing import Any, Optional, Self
from uuid import uuid4
import pgtrigger
@@ -17,6 +15,7 @@ from django.contrib.sessions.base_session import AbstractBaseSession
from django.core.validators import validate_slug
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -44,7 +43,6 @@ from authentik.lib.models import (
DomainlessFormattedURLValidator,
SerializerModel,
)
from authentik.lib.utils.inheritance import get_deepest_child
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.rbac.models import Role
@@ -52,7 +50,6 @@ from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGT
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
LOGGER = get_logger()
USERNAME_MAX_LENGTH = 150
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
_USER_ATTR_PREFIX = f"{USER_PATH_SYSTEM_PREFIX}/user"
USER_ATTRIBUTE_DEBUG = f"{_USER_ATTR_PREFIX}/debug"
@@ -186,7 +183,7 @@ class Group(SerializerModel, AttributesMixin):
default=False, help_text=_("Users added to this group will be superusers.")
)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="groups", blank=True)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
parents = models.ManyToManyField(
"Group",
@@ -228,14 +225,14 @@ class Group(SerializerModel, AttributesMixin):
# in the LDAP Outpost we use the last 5 chars so match here
return int(str(self.pk.int)[:5])
def is_member(self, user: User) -> bool:
def is_member(self, user: "User") -> bool:
"""Recursively check if `user` is member of us, or any parent."""
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this group and all of its ancestors."""
return Role.objects.filter(
groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
).distinct()
def get_managed_role(self, create=False):
@@ -243,7 +240,7 @@ class Group(SerializerModel, AttributesMixin):
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.groups.add(self)
role.ak_groups.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
@@ -358,17 +355,13 @@ class UserManager(DjangoUserManager):
class User(SerializerModel, AttributesMixin, AbstractUser):
"""authentik User model, based on django's contrib auth user model."""
# Overwriting PermissionsMixin: permissions are handled by roles.
# (This knowingly violates the Liskov substitution principle. It is better to fail loudly.)
user_permissions = None
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
name = models.TextField(help_text=_("User's display name."))
path = models.TextField(default="users")
type = models.TextField(choices=UserTypes.choices, default=UserTypes.INTERNAL)
sources = models.ManyToManyField("Source", through="UserSourceConnection")
groups = models.ManyToManyField("Group", related_name="users")
ak_groups = models.ManyToManyField("Group", related_name="users")
roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True)
password_change_date = models.DateTimeField(auto_now_add=True)
@@ -382,6 +375,8 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
permissions = [
("reset_user_password", _("Reset Password")),
("impersonate", _("Can impersonate other users")),
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
@@ -405,11 +400,11 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of."""
return self.groups.all().with_ancestors()
return self.ak_groups.all().with_ancestors()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this user and all of its groups (recursively)."""
return Role.objects.filter(Q(users=self) | Q(groups__in=self.all_groups())).distinct()
return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct()
def get_managed_role(self, create=False):
if create:
@@ -471,7 +466,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
always_merger.merge(final_attributes, self.attributes)
return final_attributes
def app_entitlements(self, app: Application | None) -> QuerySet[ApplicationEntitlement]:
def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]:
"""Get all entitlements this user has for `app`."""
if not app:
return []
@@ -490,7 +485,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
).order_by("name")
return qs
def app_entitlements_attributes(self, app: Application | None) -> dict:
def app_entitlements_attributes(self, app: "Application | None") -> dict:
"""Get a dictionary containing all merged attributes from app entitlements for `app`."""
final_attributes = {}
for attrs in self.app_entitlements(app).values_list("attributes", flat=True):
@@ -513,54 +508,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser # type: ignore
# TODO: remove this after 2026.
@property
def ak_groups(self):
"""This is a proxy for a renamed, deprecated field."""
from authentik.events.models import Event, EventAction
deprecation = "authentik.core.models.User.ak_groups"
replacement = "authentik.core.models.User.groups"
message_logger = (
f"{deprecation} is deprecated and will be removed in a future version of "
f"authentik. Please use {replacement} instead."
)
message_event = (
f"{message_logger} This event will not be repeated until it expires (by "
"default: in 30 days). See authentik logs for every will invocation of this "
"deprecation."
)
stacktrace = traceback.format_stack()
# The last line is this function, the next-to-last line is its caller
cause = stacktrace[-2] if len(stacktrace) > 1 else "Unknown, see stacktrace in logs"
if search := re.search(r'"(.*?)"', cause):
cause = f"Property mapping or Expression policy named {search.group(1)}"
LOGGER.warning(
"deprecation used",
message=message_logger,
deprecation=deprecation,
replacement=replacement,
cause=cause,
stacktrace=stacktrace,
)
if not Event.filter_not_expired(
action=EventAction.CONFIGURATION_WARNING,
context__deprecation=deprecation,
context__cause=cause,
).exists():
event = Event.new(
EventAction.CONFIGURATION_WARNING,
deprecation=deprecation,
replacement=replacement,
message=message_event,
cause=cause,
)
event.expires = datetime.now() + timedelta(days=30)
event.save()
return self.groups
def set_password(self, raw_password, signal=True, sender=None, request=None):
if self.pk and signal:
from authentik.core.signals import password_changed
@@ -707,7 +654,7 @@ class BackchannelProvider(Provider):
class ApplicationQuerySet(QuerySet):
def with_provider(self) -> QuerySet[Application]:
def with_provider(self) -> "QuerySet[Application]":
qs = self.select_related("provider")
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
qs = qs.select_related(f"provider__{subclass}")
@@ -766,15 +713,9 @@ class Application(SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
@property
def get_meta_icon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for meta_icon if it contains %(theme)s"""
if not self.meta_icon:
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.meta_icon)
def get_launch_url(self, user: User | None = None, user_data: dict | None = None) -> str | None:
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:
@@ -803,7 +744,25 @@ class Application(SerializerModel, PolicyBindingModel):
"""Get casted provider instance. Needs Application queryset with_provider"""
if not self.provider:
return None
return get_deepest_child(self.provider)
candidates = []
base_class = Provider
for subclass in base_class.objects.get_queryset()._get_subclasses_recurse(base_class):
parent = self.provider
for level in subclass.split(LOOKUP_SEP):
try:
parent = getattr(parent, level)
except AttributeError:
break
if parent in candidates:
continue
idx = subclass.count(LOOKUP_SEP)
if type(parent) is not base_class:
idx += 1
candidates.insert(idx, parent)
if not candidates:
return None
return candidates[-1]
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
"""Get Backchannel provider for a specific type"""
@@ -970,14 +929,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
@property
def icon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for icon if it contains %(theme)s"""
if not self.icon:
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.icon)
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""
try:
@@ -997,7 +948,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
raise NotImplementedError
@property
def property_mapping_type(self) -> type[PropertyMapping]:
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
@@ -1115,14 +1066,10 @@ class ExpiringModel(models.Model):
default the object is deleted. This is less efficient compared
to bulk deleting objects, but classes like Token() need to change
values instead of being deleted."""
try:
return self.delete(*args, **kwargs)
except self.DoesNotExist:
# Object has already been deleted, so this should be fine
return None
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet[Self]:
def filter_not_expired(cls, **kwargs) -> QuerySet["Self"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
@@ -1318,7 +1265,7 @@ class AuthenticatedSession(SerializerModel):
return f"Authenticated Session {str(self.pk)[:10]}"
@staticmethod
def from_request(request: HttpRequest, user: User) -> AuthenticatedSession | None:
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists(
request.session.session_key

View File

@@ -66,7 +66,7 @@ class SessionStore(SessionBase):
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError, AttributeError, TypeError:
except (pickle.PickleError, AttributeError, TypeError):
# PickleError, ValueError - unpickling exceptions
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
# and their descriptors fail to initialize (e.g., missing storage)

View File

@@ -24,8 +24,7 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any]
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
login_failed = Signal()
LOGGER = get_logger()
@@ -52,7 +51,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
if session:
session.save()
if not RefreshOtherFlowsAfterAuthentication.get():
if not RefreshOtherFlowsAfterAuthentication().get():
return
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
@@ -64,7 +63,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
@receiver(post_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: AuthenticatedSession, **_):
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted"""
Session.objects.filter(session_key=instance.pk).delete()

View File

@@ -392,10 +392,10 @@ class GroupUpdateStage(StageView):
groups.append(group)
with transaction.atomic():
self.user.groups.remove(
*self.user.groups.filter(groupsourceconnection__source=self.source)
self.user.ak_groups.remove(
*self.user.ak_groups.filter(groupsourceconnection__source=self.source)
)
self.user.groups.add(*groups)
self.user.ak_groups.add(*groups)
return True

View File

@@ -49,7 +49,7 @@ class SourceMapper:
def build_object_properties(
self,
object_type: type[User | Group],
manager: PropertyMappingManager | None = None,
manager: "PropertyMappingManager | None" = None,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,

View File

@@ -21,10 +21,6 @@
{% block head_before %}
{% endblock %}
{% block interface_stylesheet %}
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/interface-%v.css' %}" />
{% endblock %}
{% include "base/theme.html" %}
<style data-id="brand-css">{{ brand_css }}</style>

View File

@@ -1,6 +1,8 @@
{% load static %}
{% load authentik_core %}
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/interface-%v.css' %}" />
{% if ui_theme == "dark" %}
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#18191a">
@@ -8,23 +10,15 @@
{% elif ui_theme == "light" %}
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#ffffff">
{% else %}
{% else %}
<script data-id="theme-script">
"use strict";
(function () {
try {
/* Ignore older theme names */
let locallyStoredTheme = window.localStorage?.getItem("theme") || null;
if (typeof locallyStoredTheme === "string") {
locallyStoredTheme = locallyStoredTheme.trim();
}
if (!(["auto", "light", "dark"].includes(locallyStoredTheme))) {
locallyStoredTheme = null;
}
const initialThemeChoice =
new URLSearchParams(window.location.search).get("theme") || locallyStoredTheme;
new URLSearchParams(window.location.search).get("theme") ||
window.localStorage?.getItem("theme");
const themeChoice =
initialThemeChoice || document.documentElement.dataset.themeChoice || "auto";

View File

@@ -44,24 +44,19 @@
{% endblock %}
</div>
</main>
<footer
name="site-footer"
aria-label="{% trans 'Site footer' %}"
class="pf-c-login__footer pf-m-dark">
<div name="flow-links" aria-label="{% trans 'Flow links' %}">
<ul class="pf-c-list pf-m-inline" part="list">
{% for link in footer_links %}
<li part="list-item">
<a part="list-item-link" href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
<li part="list-item">
<span>
{% trans 'Powered by authentik' %}
</span>
</li>
</ul>
</div>
<footer aria-label="Site footer" class="pf-c-login__footer pf-m-dark">
<ul class="pf-c-list pf-m-inline">
{% for link in footer_links %}
<li>
<a href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
<li>
<span>
{% trans 'Powered by authentik' %}
</span>
</li>
</ul>
</footer>
</div>
</div>

View File

@@ -38,7 +38,7 @@ class TestApplicationEntitlements(APITestCase):
def test_group(self):
"""Test direct group"""
group = Group.objects.create(name=generate_id())
self.user.groups.add(group)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=group, order=0)
ents = self.user.app_entitlements(self.app)
@@ -50,7 +50,7 @@ class TestApplicationEntitlements(APITestCase):
parent = Group.objects.create(name=generate_id())
group = Group.objects.create(name=generate_id())
group.parents.add(parent)
self.user.groups.add(group)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=parent, order=0)
ents = self.user.app_entitlements(self.app)

View File

@@ -107,8 +107,6 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": None,
"assigned_backchannel_application_slug": None,
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
@@ -127,7 +125,6 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -165,8 +162,6 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": None,
"assigned_backchannel_application_slug": None,
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
@@ -185,7 +180,6 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -195,7 +189,6 @@ class TestApplicationsAPI(APITestCase):
"meta_description": "",
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_launch_url": "",
"open_in_new_tab": False,
"meta_publisher": "",

View File

@@ -122,8 +122,8 @@ class TestGroupsAPI(APITestCase):
def test_superuser_update_no_perm(self):
"""Test updating a superuser group without permission"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -139,8 +139,8 @@ class TestGroupsAPI(APITestCase):
"""Test updating a superuser group without permission
and without changing the superuser status"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),

View File

@@ -1,101 +0,0 @@
"""Test interface view redirect behavior by user type"""
from django.test import TestCase
from django.urls import reverse
from authentik.brands.models import Brand
from authentik.core.models import Application, UserTypes
from authentik.core.tests.utils import create_test_brand, create_test_user
class TestInterfaceRedirects(TestCase):
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
def setUp(self):
self.app = Application.objects.create(name="test-app", slug="test-app")
self.brand: Brand = create_test_brand(default_application=self.app)
def _assert_redirects_to_app(self, url_name: str, user_type: UserTypes):
user = create_test_user(type=user_type)
self.client.force_login(user)
response = self.client.get(reverse(f"authentik_core:{url_name}"))
self.assertRedirects(
response,
reverse(
"authentik_core:application-launch", kwargs={"application_slug": self.app.slug}
),
fetch_redirect_response=False,
)
def _assert_no_redirect(self, url_name: str, user_type: UserTypes):
"""Internal users should not be redirected away."""
user = create_test_user(type=user_type)
self.client.force_login(user)
response = self.client.get(reverse(f"authentik_core:{url_name}"))
# Internal users get a 200 (rendered template) or redirect to if-user, not to the app
app_url = reverse(
"authentik_core:application-launch", kwargs={"application_slug": self.app.slug}
)
self.assertNotEqual(response.get("Location"), app_url)
# --- RootRedirectView ---
def test_root_redirect_external_user(self):
"""External users are redirected to the default app from root"""
self._assert_redirects_to_app("root-redirect", UserTypes.EXTERNAL)
def test_root_redirect_service_account(self):
"""Service accounts are redirected to the default app from root"""
self._assert_redirects_to_app("root-redirect", UserTypes.SERVICE_ACCOUNT)
def test_root_redirect_internal_service_account(self):
"""Internal service accounts are redirected to the default app from root"""
self._assert_redirects_to_app("root-redirect", UserTypes.INTERNAL_SERVICE_ACCOUNT)
def test_root_redirect_internal_user(self):
"""Internal users are NOT redirected to the app from root"""
self._assert_no_redirect("root-redirect", UserTypes.INTERNAL)
# --- BrandDefaultRedirectView (if/user/) ---
def test_if_user_external_user(self):
"""External users are redirected to the default app from if/user/"""
self._assert_redirects_to_app("if-user", UserTypes.EXTERNAL)
def test_if_user_service_account(self):
"""Service accounts are redirected to the default app from if/user/"""
self._assert_redirects_to_app("if-user", UserTypes.SERVICE_ACCOUNT)
def test_if_user_internal_service_account(self):
"""Internal service accounts are redirected to the default app from if/user/"""
self._assert_redirects_to_app("if-user", UserTypes.INTERNAL_SERVICE_ACCOUNT)
def test_if_user_internal_user(self):
"""Internal users are NOT redirected to the app from if/user/"""
self._assert_no_redirect("if-user", UserTypes.INTERNAL)
# --- BrandDefaultRedirectView (if/admin/) ---
def test_if_admin_service_account(self):
"""Service accounts are redirected to the default app from if/admin/"""
self._assert_redirects_to_app("if-admin", UserTypes.SERVICE_ACCOUNT)
def test_if_admin_internal_service_account(self):
"""Internal service accounts are redirected to the default app from if/admin/"""
self._assert_redirects_to_app("if-admin", UserTypes.INTERNAL_SERVICE_ACCOUNT)
def test_if_admin_internal_user(self):
"""Internal users are NOT redirected to the app from if/admin/"""
self._assert_no_redirect("if-admin", UserTypes.INTERNAL)
# --- No default app set ---
def test_service_account_no_default_app_access_denied(self):
"""Service accounts get access denied when no default app is configured"""
self.brand.default_application = None
self.brand.save()
user = create_test_user(type=UserTypes.SERVICE_ACCOUNT)
self.client.force_login(user)
response = self.client.get(reverse("authentik_core:if-user"))
self.assertEqual(response.status_code, 200)
self.assertIn(b"Interface can only be accessed by internal users", response.content)

View File

@@ -63,7 +63,7 @@ class TestPropertyMappingAPI(APITestCase):
PropertyMappingSerializer().validate_expression("/")
def test_types(self):
"""Test PropertyMapping's types endpoint"""
"""Test PropertyMappigns's types endpoint"""
response = self.client.get(
reverse("authentik_api:propertymapping-types"),
)

View File

@@ -54,7 +54,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -88,7 +88,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -123,7 +123,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
@@ -155,7 +155,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -189,7 +189,7 @@ class TestSourceFlowManager(FlowTestCase):
request=request,
)
self.assertFalse(stage.handle_groups())
self.assertFalse(self.user.groups.filter(name="group 1").exists())
self.assertFalse(self.user.ak_groups.filter(name="group 1").exists())
self.assertFalse(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
@@ -201,7 +201,7 @@ class TestSourceFlowManager(FlowTestCase):
other_group = Group.objects.create(name="other group")
old_group = Group.objects.create(name="old group")
new_group = Group.objects.create(name="new group")
self.user.groups.set([other_group, old_group])
self.user.ak_groups.set([other_group, old_group])
GroupOAuthSourceConnection.objects.create(
group=old_group, source=self.source, identifier=old_group.name
)
@@ -231,7 +231,7 @@ class TestSourceFlowManager(FlowTestCase):
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertFalse(self.user.groups.filter(name="old group").exists())
self.assertTrue(self.user.groups.filter(name="other group").exists())
self.assertTrue(self.user.groups.filter(name="new group").exists())
self.assertEqual(self.user.groups.count(), 2)
self.assertFalse(self.user.ak_groups.filter(name="old group").exists())
self.assertTrue(self.user.ak_groups.filter(name="other group").exists())
self.assertTrue(self.user.ak_groups.filter(name="new group").exists())
self.assertEqual(self.user.ak_groups.count(), 2)

View File

@@ -3,7 +3,6 @@
from django.test.testcases import TestCase
from authentik.core.models import User
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -19,17 +18,3 @@ class TestUsers(TestCase):
self.assertTrue(user.has_perm(perm))
user.remove_perms_from_managed_role(perm)
self.assertFalse(user.has_perm(perm))
def test_user_ak_groups(self):
"""Test user.ak_groups is a proxy for user.groups"""
user = User.objects.create(username=generate_id())
self.assertEqual(user.ak_groups, user.groups)
def test_user_ak_groups_event(self):
"""Test user.ak_groups creates exactly one event"""
user = User.objects.create(username=generate_id())
self.assertEqual(Event.objects.count(), 0)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)

View File

@@ -1,10 +1,9 @@
"""Test Users API"""
from datetime import datetime, timedelta
from datetime import datetime
from json import loads
from django.urls.base import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.brands.models import Brand
@@ -128,62 +127,13 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_recovery_duration(self):
"""Test user recovery token duration"""
Token.objects.all().delete()
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
)
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=33"},
)
self.assertEqual(response.status_code, 200)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=33)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
def test_recovery_duration_update(self):
"""Test user recovery token duration update"""
Token.objects.all().delete()
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
)
brand: Brand = create_test_brand()
brand.flow_recovery = flow
brand.save()
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=33"},
)
self.assertEqual(response.status_code, 200)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=33)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
response = self.client.post(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
data={"token_duration": "days=66"},
)
expires = Token.objects.first().expires
expected_expires = now() + timedelta(days=66)
self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
stage = EmailStage.objects.create(name="email")
response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
data={"email_stage": stage.pk},
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
@@ -192,8 +142,7 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz"
self.user.save()
response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
data={"email_stage": stage.pk},
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
@@ -211,7 +160,7 @@ class TestUsersAPI(APITestCase):
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"email_stage": ["This field is required."]})
self.assertJSONEqual(response.content, {"non_field_errors": "Email stage does not exist."})
def test_recovery_email(self):
"""Test user recovery link"""
@@ -229,8 +178,8 @@ class TestUsersAPI(APITestCase):
reverse(
"authentik_api:user-recovery-email",
kwargs={"pk": self.user.pk},
),
data={"email_stage": stage.pk},
)
+ f"?email_stage={stage.pk}"
)
self.assertEqual(response.status_code, 204)
@@ -791,90 +740,3 @@ class TestUsersAPI(APITestCase):
response.content,
{"name": ["This field must be unique."]},
)
def test_filter_last_login(self):
"""Test API filtering by last_login"""
from datetime import timedelta
from django.utils import timezone
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
# Create users with different last_login values
user_recent = create_test_user()
user_recent.last_login = timezone.now()
user_recent.save()
user_old = create_test_user()
user_old.last_login = timezone.now() - timedelta(days=400) # Over 1 year ago
user_old.save()
user_never = create_test_user()
user_never.last_login = None # Never logged in
user_never.save()
# Filter users who logged in before 1 year ago
one_year_ago = (timezone.now() - timedelta(days=365)).isoformat()
response = self.client.get(
reverse("authentik_api:user-list"),
data={"last_login__lt": one_year_ago},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["pk"], user_old.pk)
# Filter users who have never logged in
response = self.client.get(
reverse("authentik_api:user-list"),
data={"last_login__isnull": True},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# Should include user_never and admin (who hasn't logged in via the app)
pks = [r["pk"] for r in body["results"]]
self.assertIn(user_never.pk, pks)
def test_sort_by_last_login(self):
"""Test API sorting by last_login"""
from datetime import timedelta
from django.utils import timezone
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
user1 = create_test_user()
user1.last_login = timezone.now() - timedelta(days=10)
user1.save()
user2 = create_test_user()
user2.last_login = timezone.now() - timedelta(days=5)
user2.save()
# Ascending order (oldest first)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"ordering": "last_login"},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# Users with null last_login come first, then user1 (older), then user2 (newer)
self.assertEqual(len(body["results"]), 3)
# Descending order (newest first)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"ordering": "-last_login"},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# user2 should come before user1 (more recent login)
pks = [r["pk"] for r in body["results"]]
self.assertIn(user1.pk, pks)
self.assertIn(user2.pk, pks)
# Verify user2 comes before user1 in descending order
self.assertLess(pks.index(user2.pk), pks.index(user1.pk))

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