Compare commits

..

20 Commits

Author SHA1 Message Date
Marc 'risson' Schmitt
1588e6d130 Merge branch 'main' into lib-typing 2025-11-03 17:55:00 +01:00
Marc 'risson' Schmitt
fc0366b3f4 Merge branch 'main' into lib-typing
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-23 15:19:07 +02:00
Marc 'risson' Schmitt
db849599f5 lint
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 17:00:03 +02:00
Marc 'risson' Schmitt
267f9d9905 fix more tests
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 16:57:26 +02:00
Marc 'risson' Schmitt
2e62d7cb14 start fixing tests
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 16:54:39 +02:00
Marc 'risson' Schmitt
c4adff1b26 more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 16:27:08 +02:00
Marc 'risson' Schmitt
adcad1350d more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 16:03:37 +02:00
Marc 'risson' Schmitt
94f64882ab more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 14:30:37 +02:00
Marc 'risson' Schmitt
e0b592c035 more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 14:12:27 +02:00
Marc 'risson' Schmitt
02ae7eada7 more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-22 14:04:36 +02:00
Marc 'risson' Schmitt
c12749e3e9 more
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-21 18:56:12 +02:00
Marc 'risson' Schmitt
171e83b088 lib: typing
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-21 15:17:58 +02:00
Marc 'risson' Schmitt
219666c32d Merge branch 'main' into lib-typing
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-21 15:13:42 +02:00
Marc 'risson' Schmitt
e9ec83fd03 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-25 13:53:03 +02:00
Marc 'risson' Schmitt
123cca34a1 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-25 13:42:02 +02:00
Marc 'risson' Schmitt
3a2559b115 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-25 13:38:55 +02:00
Marc 'risson' Schmitt
a9b50c8c77 lib: typing
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-24 18:52:58 +02:00
Marc 'risson' Schmitt
b81d415faf fix paramspec
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-24 18:10:53 +02:00
Marc 'risson' Schmitt
58d5d37953 also update usage of currenttask
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-24 16:52:36 +02:00
Marc 'risson' Schmitt
cc71bb6e74 packages/django-dramatiq-postgres: typing
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-09-24 16:35:18 +02:00
2275 changed files with 252850 additions and 416583 deletions

View File

@@ -1,81 +0,0 @@
name: Bug report
description: Create a report to help us improve
labels: ["bug", "triage"]
type: bug
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report!
- type: textarea
id: describe-the-bug
attributes:
label: Describe the bug
description: "A clear and concise description of what the bug is."
placeholder: "Describe the issue"
validations:
required: true
- type: textarea
id: how-to-reproduce
attributes:
label: How to reproduce
description: "Steps to reproduce the behavior."
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: "A clear and concise description of what you expected to happen."
placeholder: "The behavior that I expect to see is [...]"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: "If applicable, add screenshots to help explain your problem."
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: "Add any other context about the problem here."
placeholder: "Also note that [...]"
validations:
required: false
- type: dropdown
id: deployment-method
attributes:
label: Deployment Method
description: "What deployment method are you using for authentik? Only Docker, Kubernetes and AWS CloudFormation are supported."
options:
- Docker
- Kubernetes
- AWS CloudFormation
- Other (please specify)
default: 0
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: "What version of authentik are you using?"
placeholder: "[e.g. 2025.10.1]"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks."
render: shell
validations:
required: false

View File

@@ -1,49 +0,0 @@
name: Documentation suggestion/problem
description: Suggest an improvement or report a problem in our docs
labels: ["area: docs", "triage"]
type: task
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this documentation issue!
- type: markdown
attributes:
value: |
**Consider opening a PR!**
If the issue is one that you can fix, or even make a good pass at, we'd appreciate a PR.
For more information about making a contribution to the docs, and using our Style Guide and our templates, refer to ["Writing documentation"](https://docs.goauthentik.io/docs/developer-docs/docs/writing-documentation).
- type: textarea
id: issue
attributes:
label: Do you see an area that can be clarified or expanded, a technical inaccuracy, or a broken link?
description: "A clear and concise description of what the problem is, or where the document can be improved."
placeholder: "I believe we need more details about [...]"
validations:
required: true
- type: input
id: link
attributes:
label: Link
description: "Provide the URL or link to the exact page in the documentation to which you are referring."
placeholder: "If there are multiple pages, list them all"
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
description: "A clear and concise description of what you suggest as a solution"
placeholder: "This issue could be resolved by [...]"
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: "Add any other context or screenshots about the documentation issue here."
placeholder: "Also note that [...]"
validations:
required: false

View File

@@ -1,41 +0,0 @@
name: Feature request
description: Suggest an idea for a feature
labels: ["enhancement", "triage"]
type: feature
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this feature request!
- type: textarea
id: related-to-problem
attributes:
label: Is your feature request related to a problem?
description: "A clear and concise description of what the problem is."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- type: textarea
id: feature
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
placeholder: "I'd like authentik to have [...]"
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Describe alternatives that you've considered
description: "A clear and concise description of any alternative solutions or features you've considered."
placeholder: "I've tried this but [...]"
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: "Add any other context or screenshots about the feature request here."
placeholder: "Also note that [...]"
validations:
required: false

39
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,39 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: bug
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
<!--
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
-->
- authentik version: [e.g. 2025.2.0]
- Deployment: [e.g. docker-compose, helm]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Question
url: https://github.com/goauthentik/authentik/discussions
about: Please ask questions via GitHub Discussions rather than creating issues.
- name: authentik Discord
url: https://discord.com/invite/jg33eMhnj6
about: For community support, visit our Discord server.

22
.github/ISSUE_TEMPLATE/docs_issue.md vendored Normal file
View File

@@ -0,0 +1,22 @@
---
name: Documentation issue
about: Suggest an improvement or report a problem
title: ""
labels: documentation
assignees: ""
---
**Do you see an area that can be clarified or expanded, a technical inaccuracy, or a broken link? Please describe.**
A clear and concise description of what the problem is, or where the document can be improved. Ex. I believe we need more details about [...]
**Provide the URL or link to the exact page in the documentation to which you are referring.**
If there are multiple pages, list them all, and be sure to state the header or section where the content is.
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the documentation issue here.
**Consider opening a PR!**
If the issue is one that you can fix, or even make a good pass at, we'd appreciate a PR. For more information about making a contribution to the docs, and using our Style Guide and our templates, refer to ["Writing documentation"](https://docs.goauthentik.io/docs/developer-docs/docs/writing-documentation).

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: ""
labels: enhancement
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,17 @@
---
name: Hackathon Idea
about: Propose an idea for the hackathon
title: ""
labels: hackathon
assignees: ""
---
**Describe the idea**
A clear concise description of the idea you want to implement
You're also free to work on existing GitHub issues, whether they be feature requests or bugs, just link the existing GitHub issue here.
<!-- Don't modify below here -->
If you want to help working on this idea or want to contribute in any other way, react to this issue with a :rocket:

View File

@@ -1,7 +0,0 @@
---
name: Blank issue
about: This issue type is only for internal use
title:
labels:
assignees:
---

32
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Question
about: Ask a question about a feature or specific configuration
title: ""
labels: question
assignees: ""
---
**Describe your question/**
A clear and concise description of what you're trying to do.
**Relevant info**
i.e. Version of other software you're using, specifics of your setup
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
<!--
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
-->
- authentik version: [e.g. 2025.2.0]
- Deployment: [e.g. docker-compose, helm]
**Additional context**
Add any other context about the problem here.

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

@@ -89,8 +89,6 @@ if should_push:
_cache_tag = "buildcache"
if image_arch:
_cache_tag += f"-{image_arch}"
if is_release:
_cache_tag += f"-{version_family}"
cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"

View File

@@ -8,78 +8,59 @@ inputs:
postgresql_version:
description: "Optional postgresql image tag"
default: "16"
working-directory:
description: |
Optional working directory if this repo isn't in the root of the actions workspace.
When set, needs to contain a trailing slash
default: ""
runs:
using: "composite"
steps:
- name: Cleanup apt
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: sudo apt-get remove --purge man-db
- name: Install apt deps
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
with:
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
update: true
upgrade: false
install-recommends: false
- name: Make space on disk
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo mkdir -p /tmp/empty/
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
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 libkrb5-dev krb5-kdc krb5-user krb5-admin-server
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v5
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v5
with:
enable-cache: true
- name: Setup python
if: ${{ contains(inputs.dependencies, 'python') }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version-file: "${{ inputs.working-directory }}pyproject.toml"
python-version-file: "pyproject.toml"
- name: Install Python deps
if: ${{ contains(inputs.dependencies, 'python') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: uv sync --all-extras --dev --frozen
- name: Setup node
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v4
with:
node-version-file: ${{ inputs.working-directory }}web/package.json
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: ${{ inputs.working-directory }}web/package-lock.json
cache-dependency-path: web/package-lock.json
registry-url: 'https://registry.npmjs.org'
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
with:
go-version-file: "${{ inputs.working-directory }}go.mod"
go-version-file: "go.mod"
- name: Setup docker cache
if: ${{ contains(inputs.dependencies, 'runtime') }}
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
with:
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- name: Setup dependencies
if: ${{ contains(inputs.dependencies, 'runtime') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
cd web && npm ci
docker compose -f .github/actions/setup/docker-compose.yml up -d
cd web && npm i
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
shell: uv run python {0}
working-directory: ${{ inputs.working-directory }}
run: |
from authentik.lib.generators import generate_id
from yaml import safe_dump

View File

@@ -1,34 +0,0 @@
services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}
volumes:
- db-data:/var/lib/postgresql
command: "-c log_statement=all"
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
ports:
- 5432:5432
restart: always
s3:
container_name: s3
image: docker.io/zenko/cloudserver
environment:
REMOTE_MANAGEMENT_DISABLE: "1"
SCALITY_ACCESS_KEY_ID: accessKey1
SCALITY_SECRET_ACCESS_KEY: secretKey1
ports:
- 8020:8000
volumes:
- s3-data:/usr/src/app/localData
- s3-metadata:/usr/src/app/localMetadata
restart: always
volumes:
db-data:
driver: local
s3-data:
driver: local
s3-metadata:
driver: local

View File

@@ -0,0 +1,22 @@
services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}
volumes:
- db-data:/var/lib/postgresql/data
command: "-c log_statement=all"
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
ports:
- 5432:5432
restart: always
redis:
image: docker.io/library/redis:7
ports:
- 6379:6379
restart: always
volumes:
db-data:
driver: local

View File

@@ -8,19 +8,19 @@ inputs:
runs:
using: "composite"
steps:
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
flags: ${{ inputs.flags }}
use_oidc: true
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
with:
flags: ${{ inputs.flags }}
file: unittest.xml
use_oidc: true
report_type: test_results
- name: PostgreSQL Logs
shell: bash
run: |
if [[ $RUNNER_DEBUG == '1' ]]; then
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
docker stop setup-postgresql-1
echo "::group::PostgreSQL Logs"
docker logs setup-postgresql-1

111
.github/dependabot.yml vendored
View File

@@ -1,7 +1,5 @@
version: 2
updates:
#region Github Actions
- package-ecosystem: "github-actions"
directories:
- /
@@ -20,11 +18,6 @@ updates:
prefix: "ci:"
labels:
- dependencies
#endregion
#region Golang
- package-ecosystem: gomod
directory: "/"
schedule:
@@ -35,74 +28,11 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion
#region Web
- package-ecosystem: npm
directories:
- "/"
- "/web"
- "/web/packages/*"
schedule:
interval: daily
time: "04:00"
labels:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
groups:
sentry:
patterns:
- "@sentry/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@eslint/*"
- "@typescript-eslint/*"
- "eslint-*"
- "eslint"
- "typescript-eslint"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
bundler:
patterns:
- "@esbuild/*"
- "esbuild*"
- "@vitest/*"
- "vitest"
rollup:
patterns:
- "@rollup/*"
- "rollup-*"
- "rollup*"
swc:
patterns:
- "@swc/*"
- "swc-*"
goauthentik:
patterns:
- "@goauthentik/*"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
#endregion
#region NPM Packages
- package-ecosystem: npm
directories:
- "/web/packages/sfe"
- "/web/packages/core"
- "/packages/esbuild-plugin-live-reload"
- "/packages/prettier-config"
- "/packages/tsconfig"
@@ -115,11 +45,12 @@ updates:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "core, web:"
prefix: "web:"
groups:
sentry:
patterns:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
@@ -135,12 +66,10 @@ updates:
patterns:
- "@storybook/*"
- "*storybook*"
bundler:
esbuild:
patterns:
- "@esbuild/*"
- "esbuild*"
- "@vitest/*"
- "vitest"
rollup:
patterns:
- "@rollup/*"
@@ -150,6 +79,9 @@ updates:
patterns:
- "@swc/*"
- "swc-*"
wdio:
patterns:
- "@wdio/*"
goauthentik:
patterns:
- "@goauthentik/*"
@@ -159,11 +91,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
#endregion
# #region Documentation
- package-ecosystem: npm
directory: "/website"
schedule:
@@ -178,7 +105,6 @@ updates:
docusaurus:
patterns:
- "@docusaurus/*"
- "@goauthentik/docusaurus-config"
build:
patterns:
- "@swc/*"
@@ -187,9 +113,7 @@ updates:
- "@rspack/binding*"
goauthentik:
patterns:
- "@goauthentik/eslint-config"
- "@goauthentik/prettier-config"
- "@goauthentik/tsconfig"
- "@goauthentik/*"
eslint:
patterns:
- "@eslint/*"
@@ -197,11 +121,6 @@ updates:
- "eslint-*"
- "eslint"
- "typescript-eslint"
#endregion
# AWS Lifecycle
- package-ecosystem: npm
directory: "/lifecycle/aws"
schedule:
@@ -212,11 +131,6 @@ updates:
prefix: "lifecycle/aws:"
labels:
- dependencies
#endregion
#region Python
- package-ecosystem: uv
directory: "/"
schedule:
@@ -227,11 +141,6 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion
#region Docker
- package-ecosystem: docker
directories:
- /
@@ -257,5 +166,3 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion

View File

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

View File

@@ -1,4 +1,3 @@
---
git:
filters:
- filter_type: file

View File

@@ -42,9 +42,9 @@ jobs:
# Needed for checkout
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -56,35 +56,37 @@ jobs:
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- name: make empty clients
if: ${{ inputs.release }}
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Setup node
if: ${{ !inputs.release }}
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: generate ts client
if: ${{ !inputs.release }}
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
id: push
with:
context: .
file: lifecycle/container/Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
@@ -95,7 +97,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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@1a059c021f1d5e9f2bd39de745d5dd3a0ef6df90 # v2
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # 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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -89,7 +85,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -18,11 +18,11 @@ jobs:
- version-2025-4
- version-2025-2
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"
mkdir -p $dir
cd $dir
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
${current}/scripts/test_docker.sh

View File

@@ -37,7 +37,7 @@ jobs:
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
@@ -45,7 +45,7 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
@@ -71,23 +71,16 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: checkout stable
run: |
set -e -o pipefail
# Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
# Previous stable tag
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# 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}"
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (stable)
@@ -95,10 +88,7 @@ jobs:
with:
postgresql_version: ${{ matrix.psql }}
- name: run migrations to stable
run: |
docker ps
docker logs setup-postgresql-1
uv run python -m lifecycle.migrate
run: uv run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x
@@ -139,7 +129,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
@@ -159,11 +149,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0
uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
- name: run integration
run: |
uv run coverage run manage.py test tests/integration
@@ -190,25 +180,21 @@ 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
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
- name: endpoints
glob: tests/e2e/test_endpoints_*
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -228,54 +214,6 @@ jobs:
if: ${{ always() }}
with:
flags: e2e
test-openid-conformance:
name: test-openid-conformance (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
- uses: ./.github/actions/test-results
if: ${{ always() }}
with:
flags: conformance
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/
ci-core-mark:
if: always()
needs:
@@ -315,7 +253,7 @@ jobs:
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@@ -34,7 +34,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
with:
version: latest
args: --timeout 5000s --verbose
@@ -42,8 +42,8 @@ jobs:
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -114,7 +114,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
@@ -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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
id: compress
uses: calibreapp/image-actions@d9c8ee5c3dc52ae4622c82ead88d658f4b16b65f # main
uses: calibreapp/image-actions@05b1cf44e88c3b041b841452482df9497f046ef7 # 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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

@@ -5,10 +5,10 @@ on:
push:
branches: [main]
paths:
- packages/tsconfig/**
- packages/docusaurus-config/**
- packages/eslint-config/**
- packages/prettier-config/**
- packages/docusaurus-config/**
- packages/tsconfig/**
- packages/esbuild-plugin-live-reload/**
workflow_dispatch:
@@ -24,28 +24,25 @@ jobs:
fail-fast: false
matrix:
package:
# The order of the `*config` packages should not be changed, as they depend on each other.
- packages/tsconfig
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/docusaurus-config
- packages/tsconfig
- packages/esbuild-plugin-live-reload
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@8cba46e29c11878d930bca7870bb54394d3e8b21 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json
- name: Install Dependencies
run: npm ci
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }}

View File

@@ -24,7 +24,7 @@ jobs:
language: ["go", "javascript", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -44,7 +44,7 @@ jobs:
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -58,7 +58,7 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: true
with:
@@ -83,19 +83,14 @@ jobs:
- radius
- rac
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # 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@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -103,17 +98,17 @@ 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -126,10 +121,10 @@ jobs:
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -244,7 +232,7 @@ jobs:
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
uses: getsentry/action-release@4f502acc1df792390abe36f2dcb03612ef144818 # v3
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -49,14 +49,8 @@ jobs:
test:
name: Pre-release test
runs-on: ubuntu-latest
needs:
- check-inputs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- run: make test-docker
bump-authentik:
name: Bump authentik version
@@ -67,16 +61,16 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
token: "${{ steps.app-token.outputs.token }}"
@@ -91,12 +85,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@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: goauthentik/action-gh-release@84da137b91a625a58fe8a34f3bd6bdb034a49138
with:
token: "${{ steps.app-token.outputs.token }}"
tag_name: "version/${{ inputs.version }}"
@@ -115,17 +108,17 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: "${{ github.repository_owner }}/helm"
token: "${{ steps.app-token.outputs.token }}"
@@ -137,7 +130,7 @@ jobs:
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
./scripts/helm-docs.sh
- name: Create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -157,17 +150,17 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: "${{ github.repository_owner }}/version"
token: "${{ steps.app-token.outputs.token }}"
@@ -175,28 +168,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@271a8d0340265f705b14b6d32b9829c1cb33d45e # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # 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@29824e69f54612133e76f7eaac726eef6c875baf # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

@@ -0,0 +1,41 @@
---
# Rename transifex pull requests to have a correct naming
# Also enables auto squash-merge
name: Translation - Auto-rename Transifex PRs
on:
pull_request:
types: [opened, reopened]
permissions:
# Permission to rename PR
pull-requests: write
jobs:
rename_pr:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- id: generate_token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get current title
id: title
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
title=$(gh pr view ${{ github.event.pull_request.number }} --json "title" -q ".title")
echo "title=${title}" >> "$GITHUB_OUTPUT"
- name: Rename
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
gh pr edit ${{ github.event.pull_request.number }} -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ github.event.pull_request.number }}
merge-method: squash

3
.gitignore vendored
View File

@@ -211,5 +211,4 @@ source_docs/
/vendor/
### Docker ###
tests/openid_conformance/exports/*.zip
compose.override.yml
docker-compose.override.yml

View File

@@ -26,10 +26,6 @@ website/api/reference
node_modules
coverage
## Vendored files
vendored
*.min.js
## Configs
*.log
*.yaml

View File

@@ -11,9 +11,6 @@
"[jsonc]": {
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
},
"[xml]": {
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
},
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [

View File

@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
lifecycle/container/ @goauthentik/infrastructure
Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
@@ -26,10 +28,6 @@ packages/django-channels-postgres @goauthentik/backend
packages/django-postgres-cache @goauthentik/backend
packages/django-dramatiq-postgres @goauthentik/backend
# Web packages
package.json @goauthentik/frontend
package-lock.json @goauthentik/frontend
packages/package.json @goauthentik/frontend
packages/package-lock.json @goauthentik/frontend
packages/docusaurus-config @goauthentik/frontend
packages/esbuild-plugin-live-reload @goauthentik/frontend
packages/eslint-config @goauthentik/frontend
@@ -38,7 +36,7 @@ packages/tsconfig @goauthentik/frontend
# Web
web/ @goauthentik/frontend
# Locale
/locale/ @goauthentik/backend @goauthentik/frontend
locale/ @goauthentik/backend @goauthentik/frontend
web/xliff/ @goauthentik/backend @goauthentik/frontend
# Docs
website/ @goauthentik/docs

View File

@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -44,7 +44,6 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
--mount=type=cache,target=/go/pkg/mod \
go mod download
@@ -58,7 +57,6 @@ COPY ./go.mod /go/src/goauthentik.io/go.mod
COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
@@ -78,9 +76,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 AS uv
FROM ghcr.io/astral-sh/uv:0.9.7@sha256:ba4857bf2a068e9bc0e64eed8563b065908a4cd6bfb66b531a9c424c8e25e142 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.14.2-slim-trixie-fips@sha256:46c0658052e43ad303da39e461ad106c499a03fabd3512d05ff586e506507242 AS python-base
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
@@ -116,7 +114,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
# postgresql
libpq-dev \
# python-kadmin-rs
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
clang libkrb5-dev sccache \
# xmlsec
libltdl-dev && \
curl https://sh.rustup.rs -sSf | sh -s -- -y
@@ -158,22 +156,17 @@ WORKDIR /
RUN apt-get update && \
apt-get upgrade -y && \
# Required for runtime
apt-get install -y --no-install-recommends \
libpq5 libmaxminddb0 ca-certificates \
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
heimdal-multidev libkadm5clnt7t64-heimdal \
libltdl7 libxslt1.1 && \
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
pip3 install --no-cache-dir --upgrade pip && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /data /media /blueprints && \
ln -s /media /data/media && \
mkdir -p /certs /media /blueprints && \
mkdir -p /authentik/.ssh && \
mkdir -p /ak-root && \
chown authentik:authentik /certs /data /data/media /media /authentik/.ssh /ak-root
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
COPY ./authentik/ /authentik
COPY ./pyproject.toml /

138
Makefile
View File

@@ -5,58 +5,35 @@ 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"
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED_INPLACE = sed -i ''
else
SED_INPLACE = sed -i
endif
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 :=
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)
UV := uv
UNAME := $(shell uname)
# 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)
ifeq ($(UNAME), 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)
BREW_LDFLAGS := -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
BREW_CPPFLAGS := -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
BREW_PKG_CONFIG_PATH := $(shell brew --prefix libxml2)/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
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)
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \
@@ -73,46 +50,47 @@ go-test:
go test -timeout 0 -v -race -cover ./...
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
uv run coverage run manage.py test --keepdb authentik
uv run coverage html
uv run coverage report
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)
uv run black $(PY_SOURCES)
uv run ruff check --fix $(PY_SOURCES)
lint-codespell: ## Reports spelling errors.
$(UV) run codespell -w
uv run codespell -w
lint: ci-bandit ci-mypy ## 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))
ifdef 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="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" 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 \
@@ -125,17 +103,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.
@@ -148,11 +120,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 -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
sed -i '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
#########################
@@ -163,10 +135,14 @@ 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 make_blueprint_schema --file blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak spectacular --file schema.yml
gen-compose:
$(UV) run scripts/generate_compose.py
uv run scripts/generate_docker_compose.py
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
@@ -174,14 +150,14 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
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 \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
/local/schema-old.yml \
/local/schema.yml
rm schema-old.yml
$(SED_INPLACE) 's/{/&#123;/g' diff.md
$(SED_INPLACE) 's/}/&#125;/g' diff.md
sed -i 's/{/&#123;/g' diff.md
sed -i 's/}/&#125;/g' diff.md
npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for TypeScript
@@ -197,7 +173,7 @@ gen-clean-go: ## Remove generated API client for Go
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" gen \
generate \
-i /local/schema.yml \
-g typescript-fetch \
@@ -213,19 +189,27 @@ 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
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 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
@@ -309,7 +293,7 @@ docs-api-clean: ## Clean generated API documentation
docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ${PWD}/scripts/test_docker.sh
@@ -321,28 +305,28 @@ test-docker:
# which makes the YAML File a lot smaller
ci--meta-debug:
$(UV) run python -V
python -V
node --version
ci-mypy: ci--meta-debug
$(UV) run mypy --strict $(PY_SOURCES)
uv run mypy --strict $(PY_SOURCES)
ci-black: ci--meta-debug
$(UV) run black --check $(PY_SOURCES)
uv run black --check $(PY_SOURCES)
ci-ruff: ci--meta-debug
$(UV) run ruff check $(PY_SOURCES)
uv run ruff check $(PY_SOURCES)
ci-codespell: ci--meta-debug
$(UV) run codespell -s
uv run codespell -s
ci-bandit: ci--meta-debug
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii
uv run bandit -r $(PY_SOURCES)
ci-pending-migrations: ci--meta-debug
$(UV) run ak makemigrations --check
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 --randomly-seed ${CI_TEST_SEED} authentik
uv run coverage report
uv run coverage xml

View File

@@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version)
| Version | Supported |
| --------- | --------- |
| 2025.12.x | ✅ |
| 2026.2.x | ✅ |
| Version | Supported |
| ---------- | ---------- |
| 2025.8.x | ✅ |
| 2025.10.x | ✅ |
## Reporting a Vulnerability
@@ -60,40 +60,6 @@ authentik reserves the right to reclassify CVSS as necessary. To determine sever
| 7.0 8.9 | High |
| 9.0 10.0 | Critical |
## Intended functionality
The following capabilities are part of intentional system design and should not be reported as security vulnerabilities:
- Expressions (property mappings/policies/prompts) can execute arbitrary Python code without safeguards.
This is expected behavior. Any user with permission to create or modify objects containing expression fields can write code that is executed within authentik. If a vulnerability allows a user without the required permissions to write or modify code and have it executed, that would be a valid security report.
However, the fact that expressions are executed as part of normal operations is not considered a privilege escalation or security vulnerability.
- Blueprints can access all files on the filesystem.
This access is intentional to allow legitimate configuration and deployment tasks. It does not represent a security problem by itself.
- Importing blueprints allows arbitrary modification of application objects.
This is intended functionality. This behavior reflects the privileged design of blueprint imports. It is "exploitable" when importing blueprints from untrusted sources without reviewing the blueprint beforehand. However, any method to create, modify or execute blueprints without the required permissions would be a valid security report.
- Flow imports may contain objects other than flows (such as policies, users, groups, etc.)
This is expected behavior as flow imports are blueprint files.
- Prompt HTML is not escaped.
Prompts intentionally allow raw HTML, including script tags, so they can be used to create interactive or customized user interface elements. Because of this, scripts within prompts may affect or interact with the surrounding page as designed.
- Open redirects that do not include tokens or other sensitive information are not considered a security vulnerability.
Redirects that only change navigation flow and do not expose session tokens, API keys, or other confidential data are considered acceptable and do not require reporting.
- Outgoing network requests are not filtered.
The destinations of outgoing network requests (HTTP, TCP, etc.) made by authentik to configurable endpoints through objects such as OAuth Sources, SSO Providers, and others are not validated. Depending on your threat model, these requests should be restricted at the network level using appropriate firewall or network policies.
## Disclosure process
1. Report from Github or Issue is reported via Email as listed above.

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2026.2.3-rc1"
VERSION = "2025.12.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

@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
def get_version_latest(self, _) -> str:
"""Get latest version from cache"""
if get_current_tenant().schema_name != get_public_schema_name():
if get_current_tenant().schema_name == get_public_schema_name():
return authentik_version()
version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover

View File

@@ -1,256 +0,0 @@
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import SAFE_METHODS
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.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.events.models import Event, EventAction
from authentik.lib.utils.reflection import get_apps
from authentik.rbac.permissions import HasPermission
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
class FileView(APIView):
pagination_class = None
parser_classes = [MultiPartParser]
def get_permissions(self):
return [
HasPermission(
"authentik_rbac.view_media_files"
if self.request.method in SAFE_METHODS
else "authentik_rbac.manage_media_files"
)()
]
class FileListParameters(PassiveSerializer):
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
search = CharField(required=False)
manageable_only = BooleanField(required=False, default=False)
class FileListSerializer(PassiveSerializer):
name = CharField()
mime_type = CharField()
url = CharField()
themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
@extend_schema(
parameters=[FileListParameters],
responses={200: FileListSerializer(many=True)},
)
@validate(FileListParameters, location="query")
def get(self, request: Request, query: FileListParameters) -> Response:
"""List files from storage backend."""
params = query.validated_data
try:
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
except ValueError as exc:
raise ValidationError(
f"Invalid usage parameter provided: {params.get('usage')}"
) from exc
# Backend is source of truth - list all files from storage
manager = get_file_manager(usage)
files = manager.list_files(manageable_only=params.get("manageable_only", False))
search_query = params.get("search", "")
if search_query:
files = filter(lambda file: search_query in file.lower(), files)
files = [
FileView.FileListSerializer(
data={
"name": file,
"url": manager.file_url(file, request),
"mime_type": get_content_type(file),
"themed_urls": manager.themed_urls(file, request),
}
)
for file in files
]
for file in files:
file.is_valid(raise_exception=True)
return Response([file.data for file in files])
class FileUploadSerializer(PassiveSerializer):
file = FileField(required=True)
name = CharField(required=False, allow_blank=True)
usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
@extend_schema(
request=FileUploadSerializer,
responses={200: None},
)
@validate(FileUploadSerializer)
def post(self, request: Request, body: FileUploadSerializer) -> Response:
"""Upload file to storage backend."""
file = body.validated_data["file"]
name = body.validated_data.get("name", "").strip()
usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
# Validate file size and type
if file.size > MAX_FILE_SIZE_BYTES:
raise ValidationError(
{
"file": [
_(
f"File size ({file.size}B) exceeds maximum allowed "
f"size ({MAX_FILE_SIZE_BYTES}B)."
)
]
}
)
try:
usage = FileApiUsage(usage_value)
except ValueError as exc:
raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
# Use original filename
if not name:
name = file.name
# Sanitize path to prevent directory traversal
validate_upload_file_name(name, ValidationError)
manager = get_file_manager(usage)
# Check if file already exists
if manager.file_exists(name):
raise ValidationError({"name": ["A file with this name already exists."]})
# Save to backend
with manager.save_file_stream(name) as f:
f.write(file.read())
Event.new(
EventAction.MODEL_CREATED,
model={
"app": "authentik_admin_files",
"model_name": "File",
"pk": name,
"name": name,
"usage": usage.value,
"mime_type": get_content_type(name),
},
).from_http(request)
return Response()
class FileDeleteParameters(PassiveSerializer):
name = CharField()
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
@extend_schema(
parameters=[FileDeleteParameters],
responses={200: None},
)
@validate(FileDeleteParameters, location="query")
def delete(self, request: Request, query: FileDeleteParameters) -> Response:
"""Delete file from storage backend."""
params = query.validated_data
validate_upload_file_name(params.get("name", ""), ValidationError)
try:
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
except ValueError as exc:
raise ValidationError(
f"Invalid usage parameter provided: {params.get('usage')}"
) from exc
manager = get_file_manager(usage)
# Delete from backend
manager.delete_file(params.get("name"))
# Audit log for file deletion
Event.new(
EventAction.MODEL_DELETED,
model={
"app": "authentik_admin_files",
"model_name": "File",
"pk": params.get("name"),
"name": params.get("name"),
"usage": usage.value,
},
).from_http(request)
return Response()
class FileUsedByView(APIView):
pagination_class = None
def get_permissions(self):
return [
HasPermission(
"authentik_rbac.view_media_files"
if self.request.method in SAFE_METHODS
else "authentik_rbac.manage_media_files"
)()
]
class FileUsedByParameters(PassiveSerializer):
name = CharField()
@extend_schema(
parameters=[FileUsedByParameters],
responses={200: UsedBySerializer(many=True)},
)
@validate(FileUsedByParameters, location="query")
def get(self, request: Request, query: FileUsedByParameters) -> Response:
params = query.validated_data
models_and_fields = {}
for app in get_apps():
for model in app.get_models():
if model._meta.abstract:
continue
for field in model._meta.get_fields():
if isinstance(field, AkFileField):
models_and_fields.setdefault(model, []).append(field.name)
used_by = []
for model, fields in models_and_fields.items():
app = model._meta.app_label
model_name = model._meta.model_name
q = Q()
for field in fields:
q |= Q(**{field: params.get("name")})
objs = get_objects_for_user(
request.user, f"{app}.view_{model_name}", model.objects.all()
)
objs = objs.filter(q)
for obj in objs:
serializer = UsedBySerializer(
data={
"app": model._meta.app_label,
"model_name": model._meta.model_name,
"pk": str(obj.pk),
"name": str(obj),
"action": DeleteAction.LEFT_DANGLING,
}
)
serializer.is_valid()
used_by.append(serializer.data)
return Response(used_by)

View File

@@ -1,8 +0,0 @@
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikFilesConfig(ManagedAppConfig):
name = "authentik.admin.files"
label = "authentik_admin_files"
verbose_name = "authentik Files"
default = True

View File

@@ -1,212 +0,0 @@
import mimetypes
from collections.abc import Callable, Generator, Iterator
from typing import cast
from django.core.cache import cache
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.admin.files.usage import FileUsage
CACHE_PREFIX = "goauthentik.io/admin/files"
LOGGER = get_logger()
# 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:
"""
Base class for file storage backends.
Class attributes:
allowed_usages: List of usages that can be used with this backend
"""
allowed_usages: list[FileUsage]
def __init__(self, usage: FileUsage):
"""
Initialize backend for the given usage type.
Args:
usage: FileUsage type enum value
"""
self.usage = usage
LOGGER.debug(
"Initializing storage backend",
backend=self.__class__.__name__,
usage=usage.value,
)
def supports_file(self, name: str) -> bool:
"""
Check if this backend can handle the given file path.
Args:
name: File path to check
Returns:
True if this backend supports this file path
"""
raise NotImplementedError
def list_files(self) -> Generator[str]:
"""
List all files stored in this backend.
Yields:
Relative file paths
"""
raise NotImplementedError
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
"""
Get URL for accessing the file.
Args:
file_path: Relative file path
request: Optional Django HttpRequest for fully qualifed URL building
use_cache: whether to retrieve the URL from cache
Returns:
URL to access the file (may be relative or absolute depending on backend)
"""
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):
"""
Base class for manageable file storage backends.
Class attributes:
name: Canonical name of the storage backend, for use in configuration.
"""
name: str
@property
def manageable(self) -> bool:
"""
Whether this backend can actually be used for management.
Used only for management check, not for created the backend
"""
raise NotImplementedError
def save_file(self, name: str, content: bytes) -> None:
"""
Save file content to storage.
Args:
file_path: Relative file path
content: File content as bytes
"""
raise NotImplementedError
def save_file_stream(self, name: str) -> Iterator:
"""
Context manager for streaming file writes.
Args:
file_path: Relative file path
Returns:
Context manager that yields a writable file-like object
FileUsage:
with backend.save_file_stream("output.csv") as f:
f.write(b"data...")
"""
raise NotImplementedError
def delete_file(self, name: str) -> None:
"""
Delete file from storage.
Args:
file_path: Relative file path
"""
raise NotImplementedError
def file_exists(self, name: str) -> bool:
"""
Check if a file exists.
Args:
file_path: Relative file path
Returns:
True if file exists, False otherwise
"""
raise NotImplementedError
def _cache_get_or_set(
self,
name: str,
request: HttpRequest | None,
default: Callable[[str, HttpRequest | None], str],
timeout: int,
) -> str:
timeout_ignore = 60
timeout = int(timeout * 0.67)
if timeout < timeout_ignore:
timeout = 0
request_key = "None"
if request is not None:
request_key = f"{request.build_absolute_uri('/')}"
cache_key = f"{CACHE_PREFIX}/{self.name}/{self.usage}/{request_key}/{name}"
return cast(str, cache.get_or_set(cache_key, lambda: default(name, request), timeout))

View File

@@ -1,131 +0,0 @@
import os
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from datetime import timedelta
from hashlib import sha256
from pathlib import Path
import jwt
from django.conf import settings
from django.db import connection
from django.http.request import HttpRequest
from django.utils.timezone import now
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
class FileBackend(ManageableBackend):
"""Local filesystem backend for file storage.
Stores files in a local directory structure:
- Path: {base_dir}/{usage}/{schema}/{filename}
- Supports full file management (upload, delete, list)
- Used when storage.backend=file (default)
"""
name = "file"
allowed_usages = list(FileUsage) # All usages
@property
def _base_dir(self) -> Path:
return Path(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.path",
CONFIG.get(f"storage.{self.name}.path", "./data"),
)
)
@property
def base_path(self) -> Path:
"""Path structure: {base_dir}/{usage}/{schema}"""
return self._base_dir / self.usage.value / connection.schema_name
@property
def manageable(self) -> bool:
# 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()
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
or (settings.DEBUG or settings.TEST)
)
def supports_file(self, name: str) -> bool:
"""We support all files"""
return True
def list_files(self) -> Generator[str]:
"""List all files returning relative paths from base_path."""
for root, _, files in os.walk(self.base_path):
for file in files:
full_path = Path(root) / file
rel_path = full_path.relative_to(self.base_path)
yield str(rel_path)
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
"""Get URL for accessing the file."""
expires_in = timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
)
def _file_url(name: str, request: HttpRequest | None) -> str:
prefix = CONFIG.get("web.path", "/")[:-1]
path = f"{self.usage.value}/{connection.schema_name}/{name}"
token = jwt.encode(
payload={
"path": path,
"exp": now() + expires_in,
"nbf": now() - timedelta(seconds=15),
},
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
algorithm="HS256",
)
url = f"{prefix}/files/{path}?token={token}"
if request is None:
return url
return request.build_absolute_uri(url)
if use_cache:
timeout = int(expires_in.total_seconds())
return self._cache_get_or_set(name, request, _file_url, timeout)
else:
return _file_url(name, request)
def save_file(self, name: str, content: bytes) -> None:
"""Save file to local filesystem."""
path = self.base_path / Path(name)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w+b") as f:
f.write(content)
@contextmanager
def save_file_stream(self, name: str) -> Iterator:
"""Context manager for streaming file writes to local filesystem."""
path = self.base_path / Path(name)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
yield f
def delete_file(self, name: str) -> None:
"""Delete file from local filesystem."""
path = self.base_path / Path(name)
path.unlink(missing_ok=True)
def file_exists(self, name: str) -> bool:
"""Check if a file exists."""
path = self.base_path / Path(name)
return path.exists()

View File

@@ -1,70 +0,0 @@
from collections.abc import Generator
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import Backend
from authentik.admin.files.usage import FileUsage
EXTERNAL_URL_SCHEMES = ["http:", "https://"]
FONT_AWESOME_SCHEME = "fa://"
class PassthroughBackend(Backend):
"""Passthrough backend for external URLs and special schemes.
Handles external resources that aren't stored in authentik:
- Font Awesome icons (fa://...)
- HTTP/HTTPS URLs (http://..., https://...)
Files that are "managed" by this backend are just passed through as-is.
No upload, delete, or listing operations are supported.
Only accessible through resolve_file_url when an external URL is detected.
"""
allowed_usages = [FileUsage.MEDIA]
def supports_file(self, name: str) -> bool:
"""Check if file path is an external URL or Font Awesome icon."""
if name.startswith(FONT_AWESOME_SCHEME):
return True
for scheme in EXTERNAL_URL_SCHEMES:
if name.startswith(scheme):
return True
return False
def list_files(self) -> Generator[str]:
"""External files cannot be listed."""
yield from []
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> 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

@@ -1,243 +0,0 @@
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from tempfile import SpooledTemporaryFile
from urllib.parse import urlsplit
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from django.db import connection
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
class S3Backend(ManageableBackend):
"""S3-compatible object storage backend.
Stores files in s3-compatible storage:
- Key prefix: {usage}/{schema}/{filename}
- Supports full file management (upload, delete, list)
- Generates presigned URLs for file access
- Used when storage.backend=s3
"""
allowed_usages = list(FileUsage) # All usages
name = "s3"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._config = {}
self._session = None
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
unset = object()
current = self._config.get(key, unset)
refreshed = CONFIG.refresh(
f"storage.{self.usage.value}.{self.name}.{key}",
CONFIG.refresh(f"storage.{self.name}.{key}", default),
)
if current is unset:
current = refreshed
self._config[key] = refreshed
return (refreshed, current != refreshed)
@property
def base_path(self) -> str:
"""S3 key prefix: {usage}/{schema}/"""
return f"{self.usage.value}/{connection.schema_name}"
@property
def bucket_name(self) -> str:
return CONFIG.get(
f"storage.{self.usage.value}.{self.name}.bucket_name",
CONFIG.get(f"storage.{self.name}.bucket_name"),
)
@property
def session(self) -> boto3.Session:
"""Create boto3 session with configured credentials."""
session_profile, session_profile_r = self._get_config("session_profile", None)
if session_profile is not None:
if session_profile_r or self._session is None:
self._session = boto3.Session(profile_name=session_profile)
return self._session
else:
return self._session
else:
access_key, access_key_r = self._get_config("access_key", None)
secret_key, secret_key_r = self._get_config("secret_key", None)
session_token, session_token_r = self._get_config("session_token", None)
if access_key_r or secret_key_r or session_token_r or self._session is None:
self._session = boto3.Session(
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
aws_session_token=session_token,
)
return self._session
else:
return self._session
@property
def client(self):
"""Create S3 client with configured endpoint and region."""
endpoint_url = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.endpoint",
CONFIG.get(f"storage.{self.name}.endpoint", None),
)
use_ssl = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.use_ssl",
CONFIG.get(f"storage.{self.name}.use_ssl", True),
)
region_name = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.region",
CONFIG.get(f"storage.{self.name}.region", None),
)
addressing_style = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.addressing_style",
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
)
return self.session.client(
"s3",
endpoint_url=endpoint_url,
use_ssl=use_ssl,
region_name=region_name,
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
)
@property
def manageable(self) -> bool:
return True
def supports_file(self, name: str) -> bool:
"""We support all files"""
return True
def list_files(self) -> Generator[str]:
"""List all files returning relative paths from base_path."""
paginator = self.client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=f"{self.base_path}/")
for page in pages:
for obj in page.get("Contents", []):
key = obj["Key"]
# Remove base path prefix to get relative path
rel_path = key.removeprefix(f"{self.base_path}/")
if rel_path: # Skip if it's just the directory itself
yield rel_path
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
"""Generate presigned URL for file access."""
use_https = CONFIG.get_bool(
f"storage.{self.usage.value}.{self.name}.secure_urls",
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
)
expires_in = int(
timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
).total_seconds()
)
def _file_url(name: str, request: HttpRequest | None) -> str:
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in,
HttpMethod="GET",
)
# Support custom domain for S3-compatible storage (so not AWS)
# Well, can't you do custom domains on AWS as well?
custom_domain = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.custom_domain",
CONFIG.get(f"storage.{self.name}.custom_domain", None),
)
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
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}"
return url
if use_cache:
return self._cache_get_or_set(name, request, _file_url, expires_in)
else:
return _file_url(name, request)
def save_file(self, name: str, content: bytes) -> None:
"""Save file to S3."""
self.client.put_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
ContentType=get_content_type(name),
)
@contextmanager
def save_file_stream(self, name: str) -> Iterator:
"""Context manager for streaming file writes to S3."""
# Keep files in memory up to 5 MB
with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file:
yield file
file.seek(0)
self.client.upload_fileobj(
Fileobj=file,
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
"ContentType": get_content_type(name),
},
)
def delete_file(self, name: str) -> None:
"""Delete file from S3."""
self.client.delete_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
)
def file_exists(self, name: str) -> bool:
"""Check if a file exists in S3."""
try:
self.client.head_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
)
return True
except ClientError:
return False

View File

@@ -1,58 +0,0 @@
from collections.abc import Generator
from pathlib import Path
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import Backend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
STATIC_ASSETS_BASE_DIR = Path("web/dist")
STATIC_ASSETS_DIRS = [Path(p) for p in ("assets/icons", "assets/images")]
STATIC_ASSETS_SOURCES_DIR = Path("web/authentik/sources")
STATIC_FILE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg"]
STATIC_PATH_PREFIX = "/static"
class StaticBackend(Backend):
"""Read-only backend for static files from web/dist/assets.
- Used for serving built-in static assets like icons and images.
- Files cannot be uploaded or deleted through this backend.
- Only accessible through resolve_file_url when a static path is detected.
"""
allowed_usages = [FileUsage.MEDIA]
def supports_file(self, name: str) -> bool:
"""Check if file path is a static path."""
return name.startswith(STATIC_PATH_PREFIX)
def list_files(self) -> Generator[str]:
"""List all static files."""
# List built-in source icons
if STATIC_ASSETS_SOURCES_DIR.exists():
for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir():
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}"
# List other static assets
for dir in STATIC_ASSETS_DIRS:
dist_dir = STATIC_ASSETS_BASE_DIR / dir
if dist_dir.exists():
for file_path in dist_dir.rglob("*"):
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
def file_url(
self,
name: str,
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
"""Get URL for static file."""
prefix = CONFIG.get("web.path", "/")[:-1]
url = f"{prefix}{name}"
if request is None:
return url
return request.build_absolute_uri(url)

View File

@@ -1,195 +0,0 @@
from pathlib import Path
from django.test import TestCase
from authentik.admin.files.backends.file import FileBackend
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
class TestFileBackend(FileTestFileBackendMixin, TestCase):
"""Test FileBackend class"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
self.backend = FileBackend(FileUsage.MEDIA)
def test_allowed_usages(self):
"""Test that FileBackend supports all usage types"""
self.assertEqual(self.backend.allowed_usages, list(FileUsage))
def test_base_path(self):
"""Test base_path property constructs correct path"""
base_path = self.backend.base_path
expected = Path(self.media_backend_path) / "media" / "public"
self.assertEqual(base_path, expected)
def test_base_path_reports_usage(self):
"""Test base_path with reports usage"""
backend = FileBackend(FileUsage.REPORTS)
base_path = backend.base_path
expected = Path(self.reports_backend_path) / "reports" / "public"
self.assertEqual(base_path, expected)
def test_list_files_empty_directory(self):
"""Test list_files returns empty when directory is empty"""
# Create the directory but keep it empty
self.backend.base_path.mkdir(parents=True, exist_ok=True)
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_list_files_with_files(self):
"""Test list_files returns all files in directory"""
base_path = self.backend.base_path
base_path.mkdir(parents=True, exist_ok=True)
# Create some test files
(base_path / "file1.txt").write_text("content1")
(base_path / "file2.png").write_text("content2")
(base_path / "subdir").mkdir()
(base_path / "subdir" / "file3.csv").write_text("content3")
files = sorted(list(self.backend.list_files()))
expected = sorted(["file1.txt", "file2.png", "subdir/file3.csv"])
self.assertEqual(files, expected)
def test_list_files_nonexistent_directory(self):
"""Test list_files returns empty when directory doesn't exist"""
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_save_file(self):
content = b"test file content"
file_name = "test.txt"
self.backend.save_file(file_name, content)
# Verify file was created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_creates_subdirectories(self):
"""Test save_file creates parent directories as needed"""
content = b"nested file content"
file_name = "subdir1/subdir2/nested.txt"
self.backend.save_file(file_name, content)
# Verify file and directories were created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_stream(self):
"""Test save_file_stream context manager writes file correctly"""
content = b"streamed content"
file_name = "stream_test.txt"
with self.backend.save_file_stream(file_name) as f:
f.write(content)
# Verify file was created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_stream_creates_subdirectories(self):
"""Test save_file_stream creates parent directories as needed"""
content = b"nested stream content"
file_name = "dir1/dir2/stream.bin"
with self.backend.save_file_stream(file_name) as f:
f.write(content)
# Verify file and directories were created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_delete_file(self):
"""Test delete_file removes existing file"""
file_name = "to_delete.txt"
# Create file first
self.backend.save_file(file_name, b"content")
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
# Delete it
self.backend.delete_file(file_name)
self.assertFalse(file_path.exists())
def test_delete_file_nonexistent(self):
"""Test delete_file handles nonexistent file gracefully"""
file_name = "does_not_exist.txt"
self.backend.delete_file(file_name)
def test_file_url(self):
"""Test file_url generates correct URL"""
file_name = "icon.png"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/files/media/public/icon.png"
self.assertEqual(url, expected)
@CONFIG.patch("web.path", "/authentik/")
def test_file_url_with_prefix(self):
"""Test file_url with web path prefix"""
file_name = "logo.svg"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/authentik/files/media/public/logo.svg"
self.assertEqual(url, expected)
def test_file_url_nested_path(self):
"""Test file_url with nested file path"""
file_name = "path/to/file.png"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/files/media/public/path/to/file.png"
self.assertEqual(url, expected)
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""
file_name = "exists.txt"
self.backend.base_path.mkdir(parents=True, exist_ok=True)
(self.backend.base_path / file_name).touch()
self.assertTrue(self.backend.file_exists(file_name))
def test_file_exists_false(self):
"""Test file_exists returns False for nonexistent file"""
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
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,67 +0,0 @@
"""Test passthrough backend"""
from django.test import TestCase
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.usage import FileUsage
class TestPassthroughBackend(TestCase):
"""Test PassthroughBackend class"""
def setUp(self):
"""Set up test fixtures"""
self.backend = PassthroughBackend(FileUsage.MEDIA)
def test_allowed_usages(self):
"""Test that PassthroughBackend only supports MEDIA usage"""
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
def test_supports_file_path_font_awesome(self):
"""Test supports_file_path returns True for Font Awesome icons"""
self.assertTrue(self.backend.supports_file("fa://user"))
self.assertTrue(self.backend.supports_file("fa://home"))
self.assertTrue(self.backend.supports_file("fa://shield"))
def test_supports_file_path_http(self):
"""Test supports_file_path returns True for HTTP URLs"""
self.assertTrue(self.backend.supports_file("http://example.com/icon.png"))
self.assertTrue(self.backend.supports_file("http://cdn.example.com/logo.svg"))
def test_supports_file_path_https(self):
"""Test supports_file_path returns True for HTTPS URLs"""
self.assertTrue(self.backend.supports_file("https://example.com/icon.png"))
self.assertTrue(self.backend.supports_file("https://cdn.example.com/logo.svg"))
def test_supports_file_path_false(self):
"""Test supports_file_path returns False for regular paths"""
self.assertFalse(self.backend.supports_file("icon.png"))
self.assertFalse(self.backend.supports_file("/static/icon.png"))
self.assertFalse(self.backend.supports_file("media/logo.svg"))
self.assertFalse(self.backend.supports_file(""))
def test_supports_file_path_invalid_scheme(self):
"""Test supports_file_path returns False for invalid schemes"""
self.assertFalse(self.backend.supports_file("ftp://example.com/file.png"))
self.assertFalse(self.backend.supports_file("file:///path/to/file.png"))
self.assertFalse(self.backend.supports_file("data:image/png;base64,abc123"))
def test_list_files(self):
"""Test list_files returns empty generator"""
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_file_url(self):
"""Test file_url returns the URL as-is"""
url = "https://example.com/icon.png"
self.assertEqual(self.backend.file_url(url), url)
def test_file_url_font_awesome(self):
"""Test file_url returns Font Awesome URL as-is"""
url = "fa://user"
self.assertEqual(self.backend.file_url(url), url)
def test_file_url_http(self):
"""Test file_url returns HTTP URL as-is"""
url = "http://cdn.example.com/logo.svg"
self.assertEqual(self.backend.file_url(url), url)

View File

@@ -1,215 +0,0 @@
from unittest import skipUnless
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3 backend functionality"""
def setUp(self):
super().setUp()
def test_base_path(self):
"""Test base_path property generates correct S3 key prefix"""
expected = "media/public"
self.assertEqual(self.media_s3_backend.base_path, expected)
def test_supports_file_path_s3(self):
"""Test supports_file_path returns True for s3 backend"""
self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
def test_list_files(self):
"""Test list_files returns relative paths"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/file1.png",
Body=b"test content",
ACL="private",
)
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/other/file1.png",
Body=b"test content",
ACL="private",
)
files = list(self.media_s3_backend.list_files())
self.assertEqual(len(files), 1)
self.assertIn("file1.png", files)
def test_list_files_empty(self):
"""Test list_files with no files"""
files = list(self.media_s3_backend.list_files())
self.assertEqual(len(files), 0)
def test_save_file(self):
"""Test save_file uploads to S3"""
content = b"test file content"
self.media_s3_backend.save_file("test.png", content)
def test_save_file_stream(self):
"""Test save_file_stream uploads to S3 using context manager"""
with self.media_s3_backend.save_file_stream("test.csv") as f:
f.write(b"header1,header2\n")
f.write(b"value1,value2\n")
def test_delete_file(self):
"""Test delete_file removes from S3"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
Body=b"test content",
ACL="private",
)
self.media_s3_backend.delete_file("test.png")
@CONFIG.patch("storage.s3.secure_urls", True)
@CONFIG.patch("storage.s3.custom_domain", None)
def test_file_url_basic(self):
"""Test file_url generates presigned URL with AWS signature format"""
url = self.media_s3_backend.file_url("test.png")
self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
self.assertIn("X-Amz-Signature=", url)
self.assertIn("test.png", url)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
Body=b"test content",
ACL="private",
)
exists = self.media_s3_backend.file_exists("test.png")
self.assertTrue(exists)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_false(self):
"""Test file_exists returns False for non-existent file"""
exists = self.media_s3_backend.file_exists("nonexistent.png")
self.assertFalse(exists)
def test_allowed_usages(self):
"""Test that S3Backend supports all usage types"""
self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
def test_reports_usage(self):
"""Test S3Backend with REPORTS usage"""
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
@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

@@ -1,42 +0,0 @@
from django.test import TestCase
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
class TestStaticBackend(TestCase):
"""Test Static backend functionality"""
def setUp(self):
"""Set up test fixtures"""
self.usage = FileUsage.MEDIA
self.backend = StaticBackend(self.usage)
def test_init(self):
"""Test StaticBackend initialization"""
self.assertEqual(self.backend.usage, self.usage)
def test_allowed_usages(self):
"""Test that StaticBackend only supports MEDIA usage"""
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
def test_supports_file_path_static_prefix(self):
"""Test supports_file_path returns True for /static prefix"""
self.assertTrue(self.backend.supports_file("/static/assets/icons/test.svg"))
self.assertTrue(self.backend.supports_file("/static/authentik/sources/icon.png"))
def test_supports_file_path_not_static(self):
"""Test supports_file_path returns False for non-static paths"""
self.assertFalse(self.backend.supports_file("web/dist/assets/icons/test.svg"))
self.assertFalse(self.backend.supports_file("web/dist/assets/images/logo.png"))
self.assertFalse(self.backend.supports_file("media/public/test.png"))
self.assertFalse(self.backend.supports_file("/media/test.svg"))
self.assertFalse(self.backend.supports_file("test.jpg"))
def test_list_files(self):
"""Test list_files includes expected files"""
files = list(self.backend.list_files())
self.assertIn("/static/authentik/sources/ldap.png", files)
self.assertIn("/static/authentik/sources/openidconnect.svg", files)
self.assertIn("/static/authentik/sources/saml.png", files)

View File

@@ -1,7 +0,0 @@
from django.db import models
from authentik.admin.files.validation import validate_file_name
class FileField(models.TextField):
default_validators = [validate_file_name]

View File

@@ -1,164 +0,0 @@
from collections.abc import Generator, Iterator
from django.core.exceptions import ImproperlyConfigured
from django.http.request import HttpRequest
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.backends.file import FileBackend
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
LOGGER = get_logger()
_FILE_BACKENDS = [
StaticBackend,
PassthroughBackend,
FileBackend,
S3Backend,
]
class FileManager:
def __init__(self, usage: FileUsage) -> None:
management_backend_name = CONFIG.get(
f"storage.{usage.value}.backend",
CONFIG.get("storage.backend", "file"),
)
self.management_backend = None
for backend in _FILE_BACKENDS:
if issubclass(backend, ManageableBackend) and backend.name == management_backend_name:
self.management_backend = backend(usage)
if self.management_backend is None:
LOGGER.warning(
f"Storage backend configuration for {usage.value} is "
f"invalid: {management_backend_name}"
)
self.backends = []
for backend in _FILE_BACKENDS:
if usage not in backend.allowed_usages:
continue
if isinstance(self.management_backend, backend):
self.backends.append(self.management_backend)
elif not issubclass(backend, ManageableBackend):
self.backends.append(backend(usage))
@property
def manageable(self) -> bool:
"""
Whether this file manager is able to manage files.
"""
return self.management_backend is not None and self.management_backend.manageable
def list_files(self, manageable_only: bool = False) -> Generator[str]:
"""
List available files.
"""
for backend in self.backends:
if manageable_only and not isinstance(backend, ManageableBackend):
continue
yield from backend.list_files()
def file_url(
self,
name: str | None,
request: HttpRequest | Request | None = None,
use_cache: bool = True,
) -> str:
"""
Get URL for accessing the file.
"""
if not name:
return ""
if isinstance(request, Request):
request = request._request
for backend in self.backends:
if backend.supports_file(name):
return backend.file_url(name, request)
LOGGER.warning(f"Could not find file backend for file: {name}")
return ""
def 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.")
def save_file(self, file_path: str, content: bytes) -> None:
"""
Save file contents to storage.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.save_file(file_path, content)
def save_file_stream(self, file_path: str) -> Iterator:
"""
Context manager for streaming file writes.
Args:
file_path: Relative file path
Returns:
Context manager that yields a writable file-like object
Usage:
with manager.save_file_stream("output.csv") as f:
f.write(b"data...")
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.save_file_stream(file_path)
def delete_file(self, file_path: str) -> None:
"""
Delete file from storage.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.delete_file(file_path)
def file_exists(self, file_path: str) -> bool:
"""
Check if a file exists.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.file_exists(file_path)
MANAGERS = {usage: FileManager(usage) for usage in list(FileUsage)}
def get_file_manager(usage: FileUsage) -> FileManager:
return MANAGERS[usage]

View File

@@ -1 +0,0 @@
"""authentik files tests"""

View File

@@ -1,264 +0,0 @@
"""test file api"""
from io import BytesIO
from django.test import TestCase
from django.urls import reverse
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction
class TestFileAPI(FileTestFileBackendMixin, TestCase):
"""test file api"""
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_upload_creates_event(self):
"""Test that uploading a file creates a FILE_UPLOADED event"""
manager = FileManager(FileUsage.MEDIA)
file_content = b"test file content"
file_name = "test-upload.png"
# Upload file
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
# Verify event was created
event = Event.objects.filter(action=EventAction.MODEL_CREATED).first()
self.assertIsNotNone(event)
assert event is not None # nosec
self.assertEqual(event.context["model"]["name"], file_name)
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
self.assertEqual(event.context["model"]["mime_type"], "image/png")
# Verify user is captured
self.assertEqual(event.user["username"], self.user.username)
self.assertEqual(event.user["pk"], self.user.pk)
manager.delete_file(file_name)
def test_delete_creates_event(self):
"""Test that deleting a file creates an event"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-delete.png"
manager.save_file(file_name, b"test content")
# Delete file
response = self.client.delete(
reverse(
"authentik_api:files",
query={
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
)
)
self.assertEqual(response.status_code, 200)
# Verify event was created
event = Event.objects.filter(action=EventAction.MODEL_DELETED).first()
self.assertIsNotNone(event)
assert event is not None # nosec
self.assertEqual(event.context["model"]["name"], file_name)
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
# Verify user is captured
self.assertEqual(event.user["username"], self.user.username)
self.assertEqual(event.user["pk"], self.user.pk)
def test_list_files_basic(self):
"""Test listing files with default parameters"""
response = self.client.get(reverse("authentik_api:files"))
self.assertEqual(response.status_code, 200)
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
def test_list_files_invalid_usage(self):
"""Test listing files with invalid usage parameter"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"usage": "invalid",
},
)
)
self.assertEqual(response.status_code, 400)
self.assertIn("not a valid choice", str(response.data))
def test_list_files_with_search(self):
"""Test listing files with search query"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"search": "ldap.png",
},
)
)
self.assertEqual(response.status_code, 200)
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
def test_list_files_with_manageable_only(self):
"""Test listing files with omit parameter"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"manageableOnly": "true",
},
)
)
self.assertEqual(response.status_code, 200)
self.assertNotIn(
{
"name": "/static/dist/assets/images/flow_background.jpg",
"mime_type": "image/jpeg",
},
response.data,
)
def test_upload_file_with_custom_path(self):
"""Test uploading file with custom path"""
manager = FileManager(FileUsage.MEDIA)
file_name = "custom/test"
file_content = b"test content"
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(manager.file_exists(file_name))
manager.delete_file(file_name)
def test_upload_file_duplicate(self):
"""Test uploading file that already exists"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-file.png"
file_content = b"test content"
manager.save_file(file_name, file_content)
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("already exists", str(response.data))
manager.delete_file(file_name)
def test_delete_without_name_parameter(self):
"""Test delete without name parameter"""
response = self.client.delete(reverse("authentik_api:files"))
self.assertEqual(response.status_code, 400)
self.assertIn("field is required", str(response.data))
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"})
)
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"])
manager.delete_file(file_name)
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>")
response = self.client.get(
reverse("authentik_api:files", query={"search": "%(theme)s", "manageableOnly": "true"})
)
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"])
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)

View File

@@ -1,175 +0,0 @@
"""Test file service layer"""
from unittest import skipUnless
from urllib.parse import urlparse
from django.http import HttpRequest
from django.test import TestCase
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import (
FileTestFileBackendMixin,
FileTestS3BackendMixin,
s3_test_server_available,
)
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
class TestResolveFileUrlBasic(TestCase):
def test_resolve_empty_path(self):
"""Test resolving empty file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("")
self.assertEqual(result, "")
def test_resolve_none_path(self):
"""Test resolving None file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url(None)
self.assertEqual(result, "")
def test_resolve_font_awesome(self):
"""Test resolving Font Awesome icon"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("fa://fa-check")
self.assertEqual(result, "fa://fa-check")
def test_resolve_http_url(self):
"""Test resolving HTTP URL"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("http://example.com/icon.png")
self.assertEqual(result, "http://example.com/icon.png")
def test_resolve_https_url(self):
"""Test resolving HTTPS URL"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("https://example.com/icon.png")
self.assertEqual(result, "https://example.com/icon.png")
def test_resolve_static_path(self):
"""Test resolving static file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("/static/authentik/sources/icon.svg")
self.assertEqual(result, "/static/authentik/sources/icon.svg")
class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
def test_resolve_storage_file(self):
"""Test resolving uploaded storage file"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png").split("?")[0]
self.assertEqual(result, "/files/media/public/test.png")
def test_resolve_full_static_with_request(self):
"""Test resolving static file with request builds absolute URI"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("/static/icon.svg", mock_request)
self.assertEqual(result, "http://example.com/static/icon.svg")
def test_resolve_full_file_backend_with_request(self):
"""Test resolving FileBackend file with request"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png", mock_request).split("?")[0]
self.assertEqual(result, "http://example.com/files/media/public/test.png")
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
@CONFIG.patch("storage.media.s3.secure_urls", False)
def test_resolve_full_s3_backend(self):
"""Test resolving S3Backend returns presigned URL as-is"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png", mock_request)
# S3 URLs should be returned as-is (already absolute)
self.assertTrue(result.startswith("http://s3.test:8080/test"))
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

@@ -1,137 +0,0 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from authentik.admin.files.validation import (
MAX_FILE_NAME_LENGTH,
MAX_PATH_COMPONENT_LENGTH,
validate_file_name,
)
class TestSanitizeFilePath(TestCase):
"""Test validate_file_name function"""
def test_sanitize_valid_filename(self):
"""Test sanitizing valid filename"""
validate_file_name("test.png")
def test_sanitize_valid_path_with_directory(self):
"""Test sanitizing valid path with directory"""
validate_file_name("images/test.png")
def test_sanitize_valid_path_with_nested_dirs(self):
"""Test sanitizing valid path with nested directories"""
validate_file_name("dir1/dir2/dir3/test.png")
def test_sanitize_with_hyphens(self):
"""Test sanitizing filename with hyphens"""
validate_file_name("test-file-name.png")
def test_sanitize_with_underscores(self):
"""Test sanitizing filename with underscores"""
validate_file_name("test_file_name.png")
def test_sanitize_with_dots(self):
"""Test sanitizing filename with multiple dots"""
validate_file_name("test.file.name.png")
def test_sanitize_strips_whitespace(self):
"""Test sanitizing filename strips whitespace"""
with self.assertRaises(ValidationError):
validate_file_name(" test.png ")
def test_sanitize_removes_duplicate_slashes(self):
"""Test sanitizing path removes duplicate slashes"""
with self.assertRaises(ValidationError):
validate_file_name("dir1//dir2///test.png")
def test_sanitize_empty_path_raises(self):
"""Test sanitizing empty path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("")
def test_sanitize_whitespace_only_raises(self):
"""Test sanitizing whitespace-only path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name(" ")
def test_sanitize_invalid_characters_raises(self):
"""Test sanitizing path with invalid characters raises ValidationError"""
invalid_paths = [
"test file.png", # space
"test@file.png", # @
"test#file.png", # #
"test$file.png", # $
"test%file.png", # % (but %(theme)s is allowed)
"test&file.png", # &
"test*file.png", # *
"test(file).png", # parentheses (but %(theme)s is allowed)
"test[file].png", # brackets
"test{file}.png", # braces
]
for path in invalid_paths:
with self.assertRaises(ValidationError):
validate_file_name(path)
def test_sanitize_absolute_path_raises(self):
"""Test sanitizing absolute path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("/absolute/path/test.png")
def test_sanitize_parent_directory_raises(self):
"""Test sanitizing path with parent directory reference raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("../test.png")
def test_sanitize_nested_parent_directory_raises(self):
"""Test sanitizing path with nested parent directory reference raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("dir1/../test.png")
def test_sanitize_starts_with_dot_raises(self):
"""Test sanitizing path starting with dot raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name(".hidden")
def test_sanitize_too_long_path_raises(self):
"""Test sanitizing too long path raises ValidationError"""
long_path = "a" * (MAX_FILE_NAME_LENGTH + 1) + ".png"
with self.assertRaises(ValidationError):
validate_file_name(long_path)
def test_sanitize_too_long_component_raises(self):
"""Test sanitizing path with too long component raises ValidationError"""
long_component = "a" * (MAX_PATH_COMPONENT_LENGTH + 1)
path = f"dir/{long_component}.png"
with self.assertRaises(ValidationError):
validate_file_name(path)
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

@@ -1,129 +0,0 @@
import shutil
import socket
from tempfile import mkdtemp
from urllib.parse import urlparse
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG, UNSET
from authentik.lib.generators import generate_id
S3_TEST_ENDPOINT = "http://localhost:8020"
def s3_test_server_available() -> bool:
"""Check if the S3 test server is reachable."""
parsed = urlparse(S3_TEST_ENDPOINT)
try:
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
return True
except OSError:
return False
class FileTestFileBackendMixin:
def setUp(self):
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
self.original_media_backend_path = CONFIG.get("storage.media.file.path", UNSET)
self.media_backend_path = mkdtemp()
CONFIG.set("storage.media.backend", "file")
CONFIG.set("storage.media.file.path", str(self.media_backend_path))
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
self.original_reports_backend_path = CONFIG.get("storage.reports.file.path", UNSET)
self.reports_backend_path = mkdtemp()
CONFIG.set("storage.reports.backend", "file")
CONFIG.set("storage.reports.file.path", str(self.reports_backend_path))
def tearDown(self):
if self.original_media_backend is not UNSET:
CONFIG.set("storage.media.backend", self.original_media_backend)
else:
CONFIG.delete("storage.media.backend")
if self.original_media_backend_path is not UNSET:
CONFIG.set("storage.media.file.path", self.original_media_backend_path)
else:
CONFIG.delete("storage.media.file.path")
shutil.rmtree(self.media_backend_path)
if self.original_reports_backend is not UNSET:
CONFIG.set("storage.reports.backend", self.original_reports_backend)
else:
CONFIG.delete("storage.reports.backend")
if self.original_reports_backend_path is not UNSET:
CONFIG.set("storage.reports.file.path", self.original_reports_backend_path)
else:
CONFIG.delete("storage.reports.file.path")
shutil.rmtree(self.reports_backend_path)
class FileTestS3BackendMixin:
def setUp(self):
s3_config_keys = {
"endpoint",
"access_key",
"secret_key",
"bucket_name",
}
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
CONFIG.set("storage.media.backend", "s3")
self.original_media_s3_settings = {}
for key in s3_config_keys:
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.media.s3.access_key", "accessKey1")
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
self.media_s3_backend = S3Backend(FileUsage.MEDIA)
self.media_s3_backend.client.create_bucket(Bucket=self.media_s3_bucket_name, ACL="private")
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
CONFIG.set("storage.reports.backend", "s3")
self.original_reports_s3_settings = {}
for key in s3_config_keys:
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
self.reports_s3_backend = S3Backend(FileUsage.REPORTS)
self.reports_s3_backend.client.create_bucket(
Bucket=self.reports_s3_bucket_name, ACL="private"
)
def tearDown(self):
def delete_objects_in_bucket(client, bucket_name):
paginator = client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=bucket_name)
for page in pages:
if "Contents" not in page:
continue
for obj in page["Contents"]:
client.delete_object(Bucket=bucket_name, Key=obj["Key"])
delete_objects_in_bucket(self.media_s3_backend.client, self.media_s3_bucket_name)
self.media_s3_backend.client.delete_bucket(Bucket=self.media_s3_bucket_name)
if self.original_media_backend is not UNSET:
CONFIG.set("storage.media.backend", self.original_media_backend)
else:
CONFIG.delete("storage.media.backend")
for k, v in self.original_media_s3_settings.items():
if v is not UNSET:
CONFIG.set(f"storage.media.s3.{k}", v)
else:
CONFIG.delete(f"storage.media.s3.{k}")
delete_objects_in_bucket(self.reports_s3_backend.client, self.reports_s3_bucket_name)
self.reports_s3_backend.client.delete_bucket(Bucket=self.reports_s3_bucket_name)
if self.original_reports_backend is not UNSET:
CONFIG.set("storage.reports.backend", self.original_reports_backend)
else:
CONFIG.delete("storage.reports.backend")
for k, v in self.original_reports_s3_settings.items():
if v is not UNSET:
CONFIG.set(f"storage.reports.s3.{k}", v)
else:
CONFIG.delete(f"storage.reports.s3.{k}")

View File

@@ -1,8 +0,0 @@
from django.urls import path
from authentik.admin.files.api import FileUsedByView, FileView
api_urlpatterns = [
path("admin/file/", FileView.as_view(), name="files"),
path("admin/file/used_by/", FileUsedByView.as_view(), name="files-used-by"),
]

View File

@@ -1,17 +0,0 @@
from enum import StrEnum
from itertools import chain
class FileApiUsage(StrEnum):
"""Usage types for file API"""
MEDIA = "media"
class FileManagedUsage(StrEnum):
"""Usage types for managed files"""
REPORTS = "reports"
FileUsage = StrEnum("FileUsage", [(v.name, v.value) for v in chain(FileApiUsage, FileManagedUsage)])

View File

@@ -1,85 +0,0 @@
import re
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
# File upload limits
MAX_FILE_NAME_LENGTH = 1024
MAX_PATH_COMPONENT_LENGTH = 255
def validate_file_name(name: str) -> None:
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
FileUsage.MEDIA
).supports_file(name):
return
validate_upload_file_name(name)
def validate_upload_file_name(
name: str,
ValidationError: type[Exception] = ValidationError,
) -> None:
"""Sanitize file path.
Args:
file_path: The file path to sanitize
Returns:
Sanitized file path
Raises:
ValidationError: If file path is invalid
"""
if not name:
raise ValidationError(_("File name cannot be empty"))
# 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):
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"
)
)
if "//" in name:
raise ValidationError(_("File name cannot contain duplicate /"))
# Convert to posix path
path = PurePosixPath(name)
# Check for absolute paths
# Needs the / at the start. If it doesn't have it, it might still be unsafe, so see L53+
if path.is_absolute():
raise ValidationError(_("Absolute paths are not allowed"))
# Check for parent directory references
if ".." in path.parts:
raise ValidationError(_("Parent directory references ('..') are not allowed"))
# Disallow paths starting with dot (hidden files at root level)
if str(path).startswith("."):
raise ValidationError(_("Paths cannot start with '.'"))
# Check path length limits
normalized = str(path)
if len(normalized) > MAX_FILE_NAME_LENGTH:
raise ValidationError(_(f"File name too long (max {MAX_FILE_NAME_LENGTH} characters)"))
for part in path.parts:
if len(part) > MAX_PATH_COMPONENT_LENGTH:
raise ValidationError(
_(f"Path component too long (max {MAX_PATH_COMPONENT_LENGTH} characters)")
)

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())
@@ -27,21 +27,83 @@ except OSError:
ipc_key = None
def validate_auth(header: bytes, format="bearer") -> str | None:
def validate_auth(header: bytes) -> str | None:
"""Validate that the header is in a correct format,
returns type and credentials"""
auth_credentials = header.decode().strip()
if auth_credentials == "" or " " not in auth_credentials:
return None
auth_type, _, auth_credentials = auth_credentials.partition(" ")
if not compare_digest(auth_type.lower(), format):
if auth_type.lower() != "bearer":
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
return None
raise AuthenticationFailed("Unsupported authentication type")
if auth_credentials == "": # nosec # noqa
raise AuthenticationFailed("Malformed header")
return auth_credentials
def bearer_auth(raw_header: bytes) -> User | None:
"""raw_header in the Format of `Bearer ....`"""
user = auth_user_lookup(raw_header)
if not user:
return None
if not user.is_active:
raise AuthenticationFailed("Token invalid/expired")
return user
def auth_user_lookup(raw_header: bytes) -> User | None:
"""raw_header in the Format of `Bearer ....`"""
from authentik.providers.oauth2.models import AccessToken
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user
# then try to auth via JWT
jwt_token = AccessToken.filter_not_expired(
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:
# Double-check scopes, since they are saved in a single string
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user
# then try to auth via secret key (for embedded outpost/etc)
user = token_secret_key(auth_credentials)
if user:
CTX_AUTH_VIA.set("secret_key")
return user
# then try to auth via secret key (for embedded outpost/etc)
user = token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
def token_secret_key(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user
class IPCUser(AnonymousUser):
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
@@ -70,8 +132,13 @@ class IPCUser(AnonymousUser):
def is_authenticated(self):
return True
def all_roles(self):
return []
def token_ipc(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser()
class TokenAuthentication(BaseAuthentication):
@@ -81,79 +148,12 @@ class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request)
user_ctx = self.bearer_auth(auth)
user = bearer_auth(auth)
# None is only returned when the header isn't set.
if not user_ctx:
if not user:
return None
return user_ctx
def bearer_auth(self, raw_header: bytes) -> tuple[User, Any] | None:
"""raw_header in the Format of `Bearer ....`"""
user_ctx = self.auth_user_lookup(raw_header)
if not user_ctx:
return None
user, ctx = user_ctx
if not user.is_active:
raise AuthenticationFailed("Token invalid/expired")
return user, ctx
def auth_user_lookup(self, raw_header: bytes) -> tuple[User, Any] | None:
"""raw_header in the Format of `Bearer ....`"""
from authentik.providers.oauth2.models import AccessToken
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user, key_token
# then try to auth via JWT
jwt_token = AccessToken.filter_not_expired(
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:
# Double-check scopes, since they are saved in a single string
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user, jwt_token
# then try to auth via secret key (for embedded outpost/etc)
user_outpost = self.token_secret_key(auth_credentials)
if user_outpost:
CTX_AUTH_VIA.set("secret_key")
return user_outpost
# then try to auth via secret key (for embedded outpost/etc)
user = self.token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
def token_ipc(self, value: str) -> tuple[User, None] | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser(), None
def token_secret_key(self, value: str) -> tuple[User, Outpost] | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user, outpost
return (user, None) # pragma: no cover
class TokenSchema(OpenApiAuthenticationExtension):

View File

@@ -1,45 +0,0 @@
from json import dumps
from django.core.management.base import BaseCommand, no_translations
from drf_spectacular.drainage import GENERATOR_STATS
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.renderers import OpenApiYamlRenderer
from drf_spectacular.validation import validate_schema
from structlog.stdlib import get_logger
from authentik.blueprints.v1.schema import SchemaBuilder
class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = get_logger()
def add_arguments(self, parser):
parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json")
parser.add_argument("--api-file", type=str, default="schema.yml")
@no_translations
def handle(self, *args, blueprint_file: str, api_file: str, **options):
self.build_blueprint(blueprint_file)
self.build_api(api_file)
def build_blueprint(self, file: str):
self.logger.debug("Building blueprint schema...", file=file)
blueprint_builder = SchemaBuilder()
blueprint_builder.build()
with open(file, "w") as _schema:
_schema.write(
dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default)
)
def build_api(self, file: str):
self.logger.debug("Building API schema...", file=file)
generator = SchemaGenerator()
schema = generator.get_schema(request=None, public=True)
GENERATOR_STATS.emit_summary()
validate_schema(schema)
output = OpenApiYamlRenderer().render(schema, renderer_context={})
with open(file, "wb") as f:
f.write(output)

View File

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

View File

@@ -2,21 +2,20 @@
import json
from base64 import b64encode
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import IPCUser, TokenAuthentication
from authentik.api.authentication import bearer_auth
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.models import Token, TokenIntents, User, 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
@@ -25,24 +24,24 @@ class TestAPIAuth(TestCase):
def test_invalid_type(self):
"""Test invalid type"""
self.assertIsNone(TokenAuthentication().bearer_auth(b"foo bar"))
with self.assertRaises(AuthenticationFailed):
bearer_auth(b"foo bar")
def test_invalid_empty(self):
"""Test invalid type"""
self.assertIsNone(TokenAuthentication().bearer_auth(b"Bearer "))
self.assertIsNone(TokenAuthentication().bearer_auth(b""))
self.assertIsNone(bearer_auth(b"Bearer "))
self.assertIsNone(bearer_auth(b""))
def test_invalid_no_token(self):
"""Test invalid with no token"""
auth = b64encode(b":abc").decode()
self.assertIsNone(TokenAuthentication().bearer_auth(f"Basic :{auth}".encode()))
with self.assertRaises(AuthenticationFailed):
auth = b64encode(b":abc").decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
def test_bearer_valid(self):
"""Test valid token"""
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
user, tk = TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
self.assertEqual(user, token.user)
self.assertEqual(token, token)
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
def test_bearer_valid_deactivated(self):
"""Test valid token"""
@@ -51,7 +50,7 @@ class TestAPIAuth(TestCase):
user.save()
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
with self.assertRaises(AuthenticationFailed):
TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
bearer_auth(f"Bearer {token.key}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_fail(self):
@@ -60,21 +59,20 @@ class TestAPIAuth(TestCase):
outpost.user.delete()
outpost.delete()
with self.assertRaises(AuthenticationFailed):
TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
user, outpost = TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
self.assertEqual(outpost, Outpost.objects.filter(managed=MANAGED_OUTPOST).first())
def test_jwt_valid(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
access = AccessToken.objects.create(
refresh = AccessToken.objects.create(
user=create_test_admin_user(),
provider=provider,
token=generate_id(),
@@ -82,16 +80,14 @@ class TestAPIAuth(TestCase):
_scope=SCOPE_AUTHENTIK_API,
_id_token=json.dumps({}),
)
user, token = TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
self.assertEqual(user, access.user)
self.assertEqual(token, access)
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
def test_jwt_missing_scope(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
access = AccessToken.objects.create(
refresh = AccessToken.objects.create(
user=create_test_admin_user(),
provider=provider,
token=generate_id(),
@@ -100,12 +96,4 @@ class TestAPIAuth(TestCase):
_id_token=json.dumps({}),
)
with self.assertRaises(AuthenticationFailed):
TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
def test_ipc(self):
"""Test IPC auth (mock key)"""
key = generate_id()
with patch("authentik.api.authentication.ipc_key", key):
user, ctx = TokenAuthentication().bearer_auth(f"Bearer {key}".encode())
self.assertEqual(user, IPCUser())
self.assertEqual(ctx, None)
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)

View File

@@ -1,16 +1,9 @@
"""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
from rest_framework.test import APITestCase
from yaml import safe_load
from authentik.lib.config import CONFIG
class TestSchemaGeneration(APITestCase):
"""Generic admin tests"""
@@ -28,17 +21,3 @@ class TestSchemaGeneration(APITestCase):
reverse("authentik_api:schema-browser"),
)
self.assertEqual(response.status_code, 200)
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"
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)
self.assertTrue(blueprint_file.exists())
self.assertTrue(api_file.exists())

View File

@@ -1,62 +0,0 @@
from collections.abc import Callable
from inspect import getmembers
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from authentik.lib.utils.reflection import all_subclasses
class TestAPIViewAuthnAuthz(APITestCase): ...
def api_viewset_action(viewset: GenericViewSet, member: Callable) -> Callable:
"""Test API Viewset action"""
def tester(self: TestAPIViewAuthnAuthz):
if "permission_classes" in member.kwargs:
self.assertNotEqual(
member.kwargs["permission_classes"], [], "permission_classes should not be empty"
)
if "authentication_classes" in member.kwargs:
self.assertNotEqual(
member.kwargs["authentication_classes"],
[],
"authentication_classes should not be empty",
)
return tester
def api_view(view: APIView) -> Callable:
def tester(self: TestAPIViewAuthnAuthz):
self.assertNotEqual(view.permission_classes, [], "permission_classes should not be empty")
self.assertNotEqual(
view.authentication_classes,
[],
"authentication_classes should not be empty",
)
return tester
# Tell django to load all URLs
reverse("authentik_core:root-redirect")
for viewset in all_subclasses(GenericViewSet):
for act_name, member in getmembers(viewset(), lambda x: isinstance(x, Callable)):
if not hasattr(member, "kwargs") or not hasattr(member, "mapping"):
continue
setattr(
TestAPIViewAuthnAuthz,
f"test_viewset_{viewset.__name__}_action_{act_name}",
api_viewset_action(viewset, member),
)
for view in all_subclasses(APIView):
setattr(
TestAPIViewAuthnAuthz,
f"test_view_{view.__name__}",
api_view(view),
)

View File

@@ -1,73 +1,31 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from urllib.parse import urlencode
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.api.v3.urls import router
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.tenants.api.domains import DomainViewSet
from authentik.tenants.api.tenants import TenantViewSet
from authentik.tenants.utils import get_current_tenant
class TestModelViewSets(TestCase):
"""Test Viewset"""
def setUp(self):
self.user = create_test_admin_user()
self.factory = RequestFactory()
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
"""Test Viewset"""
def test_attrs(self: TestModelViewSets) -> None:
"""Test attributes we require on all viewsets"""
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
def test_ordering(self: TestModelViewSets) -> None:
"""Test that all ordering fields are correct"""
view = test_viewset.as_view({"get": "list"})
for ordering_field in test_viewset.ordering:
with self.subTest(ordering_field):
req = self.factory.get(
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
def test_search(self: TestModelViewSets) -> None:
"""Test that search fields are correct"""
view = test_viewset.as_view({"get": "list"})
req = self.factory.get(
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
cases = {
"attrs": test_attrs,
}
if full:
cases["ordering"] = test_ordering
cases["search"] = test_search
return cases
return tester
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
continue
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
for test, case in viewset_tester_factory(viewset, full=full).items():
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))

View File

@@ -1,9 +1,10 @@
"""core Configs API"""
from pathlib import Path
from django.conf import settings
from django.db import models
from django.dispatch import Signal
from django.http import HttpRequest
from drf_spectacular.utils import extend_schema
from rest_framework.fields import (
BooleanField,
@@ -18,8 +19,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileUsage
from authentik.core.api.utils import PassiveSerializer
from authentik.events.context_processors.base import get_context_processors
from authentik.lib.config import CONFIG
@@ -31,7 +30,6 @@ class Capabilities(models.TextChoices):
"""Define capabilities which influence which APIs can/should be used"""
CAN_SAVE_MEDIA = "can_save_media"
CAN_SAVE_REPORTS = "can_save_reports"
CAN_GEO_IP = "can_geo_ip"
CAN_ASN = "can_asn"
CAN_IMPERSONATE = "can_impersonate"
@@ -65,30 +63,31 @@ class ConfigView(APIView):
permission_classes = [AllowAny]
@staticmethod
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
def get_capabilities(self) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
if get_file_manager(FileUsage.MEDIA).manageable:
deb_test = settings.DEBUG or settings.TEST
if (
CONFIG.get("storage.media.backend", "file") == "s3"
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
or deb_test
):
caps.append(Capabilities.CAN_SAVE_MEDIA)
if get_file_manager(FileUsage.REPORTS).manageable:
caps.append(Capabilities.CAN_SAVE_REPORTS)
for processor in get_context_processors():
if cap := processor.capability():
caps.append(cap)
if request.tenant.impersonation:
if self.request.tenant.impersonation:
caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG)
if "authentik.enterprise" in settings.INSTALLED_APPS:
caps.append(Capabilities.IS_ENTERPRISE)
for _, result in capabilities.send(sender=ConfigView):
for _, result in capabilities.send(sender=self):
if result:
caps.append(result)
return caps
@staticmethod
def get_config(request: HttpRequest) -> ConfigSerializer:
def get_config(self) -> ConfigSerializer:
"""Get Config"""
return ConfigSerializer(
{
@@ -99,7 +98,7 @@ class ConfigView(APIView):
"send_pii": CONFIG.get("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
},
"capabilities": ConfigView.get_capabilities(request),
"capabilities": self.get_capabilities(),
"cache_timeout": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
@@ -109,4 +108,4 @@ class ConfigView(APIView):
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
return Response(ConfigView.get_config(request).data)
return Response(self.get_config().data)

View File

@@ -1,50 +0,0 @@
from collections.abc import Callable
from functools import wraps
from typing import Literal
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
def validate(serializer_type: type[Serializer], location: Literal["body", "query"] = "body"):
"""Validate incoming data with the specified serializer. Raw data can either be taken
from request body or query string, defaulting to body.
Validated data is added to the function this decorator is used on with a named parameter
based on the location of the data.
Example:
@validate(MySerializer)
@validate(MyQuerySerializer, location="query")
def my_action(self, request, *, body: MySerializer, query: MyQuerySerializer):
...
"""
def wrapper_outer(func: Callable):
@wraps(func)
def wrapper(self: ViewSet, request: Request, *args, **kwargs) -> Response:
data = {}
if location == "body":
data = request.data
elif location == "query":
data = request.query_params
else:
raise ValueError(f"Invalid data location '{location}'")
instance = serializer_type(
data=data,
context={
"request": request,
},
)
instance.is_valid(raise_exception=True)
kwargs[location] = instance
return func(self, request, *args, **kwargs)
return wrapper
return wrapper_outer

View File

@@ -1,11 +1,10 @@
"""authentik Blueprints app"""
import traceback
from collections.abc import Callable
from importlib import import_module
from inspect import ismethod
from django.apps import AppConfig
from django.conf import settings
from django.db import DatabaseError, InternalError, ProgrammingError
from dramatiq.broker import get_broker
from structlog.stdlib import BoundLogger, get_logger
@@ -45,21 +44,8 @@ class ManagedAppConfig(AppConfig):
module_name = f"{self.name}.{rel_module}"
import_module(module_name)
self.logger.info("Imported related module", module=module_name)
except ModuleNotFoundError as exc:
if settings.DEBUG:
# This is a heuristic for determining whether the exception was caused
# "directly" by the `import_module` call or whether the initial import
# succeeded and a later import (within the existing module) failed.
# 1. <the calling function>
# 2. importlib.import_module
# 3. importlib._bootstrap._gcd_import
# 4. importlib._bootstrap._find_and_load
# 5. importlib._bootstrap._find_and_load_unlocked
STACK_LENGTH_HEURISTIC = 5
stack_length = len(traceback.extract_tb(exc.__traceback__))
if stack_length > STACK_LENGTH_HEURISTIC:
raise
except ModuleNotFoundError:
pass
import_relative("checks")
import_relative("tasks")
@@ -71,19 +57,12 @@ class ManagedAppConfig(AppConfig):
def _reconcile(self, prefix: str) -> None:
for meth_name in dir(self):
# Check the attribute on the class to avoid evaluating @property descriptors.
# Using getattr(self, ...) on a @property would evaluate it, which can trigger
# expensive side effects (e.g. tenant_schedule_specs iterating all providers
# and running PolicyEngine queries for every user).
class_attr = getattr(type(self), meth_name, None)
if class_attr is None or isinstance(class_attr, property):
meth = getattr(self, meth_name)
if not ismethod(meth):
continue
if not callable(class_attr):
continue
category = getattr(class_attr, "_authentik_managed_reconcile", None)
category = getattr(meth, "_authentik_managed_reconcile", None)
if category != prefix:
continue
meth = getattr(self, meth_name)
name = meth_name.replace(prefix, "")
try:
self.logger.debug("Starting reconciler", name=name)

View File

@@ -1,9 +1,10 @@
"""Generate JSON Schema for blueprints"""
from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from django.db.models.fields.related import OneToOneField
from drf_jsonschema_serializer.convert import converter, field_to_converter
from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.relations import PrimaryKeyRelatedField
@@ -31,19 +32,18 @@ class PrimaryKeyRelatedFieldConverter:
def convert(self, field: PrimaryKeyRelatedField):
model: Model = field.queryset.model
pk_field = model._meta.pk
if isinstance(pk_field, OneToOneField):
pk_field = pk_field.related_fields[0][1]
if isinstance(pk_field, fields.UUIDField):
return {"type": "string", "format": "uuid"}
return {"type": "integer"}
class SchemaBuilder:
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
schema: dict
def __init__(self):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
@@ -90,6 +90,16 @@ class SchemaBuilder:
"$defs": {"blueprint_entry": {"oneOf": []}},
}
def add_arguments(self, parser):
parser.add_argument("--file", type=str)
@no_translations
def handle(self, *args, file: str, **options):
"""Generate JSON Schema for blueprints"""
self.build()
with open(file, "w") as _schema:
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
@staticmethod
def json_default(value: Any) -> Any:
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
@@ -108,10 +118,7 @@ class SchemaBuilder:
model_instance: Model = model()
if not isinstance(model_instance, SerializerModel):
continue
try:
serializer_class = model_instance.serializer
except NotImplementedError as exc:
raise ValueError(f"SerializerModel not implemented by {model}") from exc
serializer_class = model_instance.serializer
serializer = serializer_class(
context={
SERIALIZER_CONTEXT_BLUEPRINT: False,

View File

@@ -2,23 +2,27 @@
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
from django.apps import apps
from authentik.blueprints.apps import ManagedAppConfig
from authentik.blueprints.models import BlueprintInstance
P = ParamSpec("P")
R = TypeVar("R")
def apply_blueprint(*files: str):
def apply_blueprint(*files: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Apply blueprint before test"""
from authentik.blueprints.v1.importer import Importer
def wrapper_outer(func: Callable):
def wrapper_outer(func: Callable[P, R]) -> Callable[P, R]:
"""Apply blueprint before test"""
@wraps(func)
def wrapper(*args, **kwargs):
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for file in files:
content = BlueprintInstance(path=file).retrieve()
Importer.from_string(content).apply()

View File

@@ -8,62 +8,45 @@ metadata:
- Application (icon)
- Source (icon)
- Flow (background)
- Endpoint Enrollment token (key)
entries:
token:
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
app:
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
source:
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
flow:
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
user:
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"
endpoint:
- model: authentik_endpoints_connectors_agent.agentconnector
id: connector
identifiers:
name: "%(uid)s"
- model: authentik_endpoints_connectors_agent.enrollmenttoken
identifiers:
name: "%(uid)s"
attrs:
key: "%(uid)s"
connector: !KeyOf connector
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"

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

@@ -3,11 +3,12 @@
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import Token, User
from authentik.core.models import Application, Token, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import EnrollmentToken
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.sources.oauth.models import OAuthSource
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
@@ -28,20 +29,32 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
self.assertIsNotNone(token)
self.assertEqual(token.key, self.uid)
def test_application(self):
"""Test application"""
app = Application.objects.filter(slug=f"{self.uid}-app").first()
self.assertIsNotNone(app)
self.assertEqual(app.meta_icon, "https://goauthentik.io/img/icon.png")
def test_source(self):
"""Test source"""
source = OAuthSource.objects.filter(slug=f"{self.uid}-source").first()
self.assertIsNotNone(source)
self.assertEqual(source.icon, "https://goauthentik.io/img/icon.png")
def test_flow(self):
"""Test flow"""
flow = Flow.objects.filter(slug=f"{self.uid}-flow").first()
self.assertIsNotNone(flow)
self.assertEqual(flow.background, "https://goauthentik.io/img/icon.png")
def test_user(self):
"""Test user"""
user = User.objects.filter(username=self.uid).first()
user: User = User.objects.filter(username=self.uid).first()
self.assertIsNotNone(user)
self.assertTrue(user.check_password(self.uid))
def test_user_null(self):
"""Test user"""
user = User.objects.filter(username=f"{self.uid}-no-password").first()
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
self.assertIsNotNone(user)
self.assertFalse(user.has_usable_password())
def test_enrollment_token(self):
"""Test endpoint enrollment token"""
token = EnrollmentToken.objects.filter(name=self.uid).first()
self.assertIsNotNone(token)
self.assertEqual(token.key, self.uid)

View File

@@ -36,7 +36,10 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
self.assertTrue(importer.apply())
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(role)
self.assertEqual(get_perms(role), {"authentik_blueprints.view_blueprintinstance"})
self.assertEqual(
list(role.group.permissions.all().values_list("codename", flat=True)),
["view_blueprintinstance"],
)
def test_object_permission(self):
"""Test permissions"""
@@ -50,5 +53,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
user = User.objects.filter(username=uid).first()
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(flow)
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})
self.assertEqual(get_perms(user, flow), ["view_flow"])
self.assertEqual(get_perms(role.group, flow), ["view_flow"])

View File

@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint.send(instance.pk).get_result(block=True)
apply_blueprint(instance.pk)
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(

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,9 @@ 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 django_channels_postgres.models import GroupChannel, Message
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@@ -40,16 +42,46 @@ from authentik.core.models import (
User,
UserSourceConnection,
)
from authentik.endpoints.models import Connector
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.ssf.models import StreamEvent
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.utils import cleanse_dict
from authentik.flows.models import Stage
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.proxy.models import ProxySession
from authentik.providers.rac.models import ConnectionToken
from authentik.providers.saml.models import SAMLSession
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.stages.consent.models import UserConsent
from authentik.tasks.models import Task, TaskLog
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used
@@ -69,7 +101,7 @@ def excluded_models() -> list[type[Model]]:
DjangoGroup,
ContentType,
Permission,
RoleObjectPermission,
UserObjectPermission,
# Base classes
Provider,
Source,
@@ -80,20 +112,45 @@ def excluded_models() -> list[type[Model]]:
OutpostServiceConnection,
Policy,
PolicyBindingModel,
Connector,
# Classes that have other dependencies
Session,
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMProviderGroup,
SCIMProviderUser,
Tenant,
Task,
TaskLog,
ConnectionToken,
AuthorizationCode,
AccessToken,
RefreshToken,
ProxySession,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
DeviceToken,
StreamEvent,
UserConsent,
SAMLSession,
Message,
GroupChannel,
)
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return (
model not in excluded_models()
and issubclass(model, SerializerModel | BaseMetaModel)
and not issubclass(model, InternallyManagedMixin)
)
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
class DoRollback(SentryIgnoredException):
@@ -139,22 +196,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:
@@ -329,12 +377,10 @@ class Importer:
"""Apply object-level permissions for an entry"""
for perm in entry.get_permissions(self._import):
if perm.user is not None:
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
perm.permission, instance
)
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
if perm.role is not None:
role = Role.objects.get(pk=perm.role)
role.assign_perms(perm.permission, obj=instance)
role.assign_permission(perm.permission, obj=instance)
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""

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
@@ -37,21 +37,14 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
return super().validate(attrs)
def create(self, validated_data: dict) -> MetaResult:
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.tasks import apply_blueprint
if not self.blueprint_instance:
LOGGER.info("Blueprint does not exist, but not required")
return MetaResult()
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
# Apply blueprint directly using Importer to avoid task context requirements
# and prevent deadlocks when called from within another blueprint task
blueprint_content = self.blueprint_instance.retrieve()
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
valid, logs = importer.validate()
[log.log() for log in logs]
if valid:
importer.apply()
apply_blueprint(self.blueprint_instance.pk)
return MetaResult()

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