mirror of
https://github.com/goauthentik/authentik
synced 2026-05-13 02:16:30 +02:00
Compare commits
1 Commits
separate-l
...
language-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd7464bd8 |
6
.github/actions/cherry-pick/action.yml
vendored
6
.github/actions/cherry-pick/action.yml
vendored
@@ -215,9 +215,6 @@ runs:
|
|||||||
--head "$CHERRY_PICK_BRANCH" \
|
--head "$CHERRY_PICK_BRANCH" \
|
||||||
--label "cherry-pick")
|
--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"
|
echo "✅ Created cherry-pick PR $NEW_PR for $TARGET_BRANCH"
|
||||||
|
|
||||||
# Comment on original PR
|
# Comment on original PR
|
||||||
@@ -257,9 +254,6 @@ runs:
|
|||||||
--head "$CHERRY_PICK_BRANCH" \
|
--head "$CHERRY_PICK_BRANCH" \
|
||||||
--label "cherry-pick")
|
--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"
|
echo "⚠️ Created conflict resolution PR $NEW_PR for $TARGET_BRANCH"
|
||||||
|
|
||||||
# Comment on original PR
|
# Comment on original PR
|
||||||
|
|||||||
35
.github/actions/setup/action.yml
vendored
35
.github/actions/setup/action.yml
vendored
@@ -12,65 +12,52 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt deps & cleanup
|
- name: Install apt deps
|
||||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get remove --purge man-db
|
sudo apt-get remove --purge man-db
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||||
sudo rm -rf /usr/local/lib/android
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v5
|
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
- name: Setup python
|
- name: Setup python
|
||||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: "pyproject.toml"
|
||||||
- name: Install Python deps
|
- name: Install Python deps
|
||||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: uv sync --all-extras --dev --frozen
|
run: uv sync --all-extras --dev --frozen
|
||||||
- name: Setup node (web)
|
- name: Setup node
|
||||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: 'https://registry.npmjs.org'
|
||||||
- name: Setup node (root)
|
|
||||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
|
||||||
with:
|
|
||||||
node-version-file: package.json
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
- name: Install Node deps
|
|
||||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
|
||||||
shell: bash
|
|
||||||
run: npm ci
|
|
||||||
- name: Setup go
|
- name: Setup go
|
||||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
|
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup docker cache
|
- name: Setup docker cache
|
||||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||||
with:
|
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
|
- name: Setup dependencies
|
||||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||||
docker compose -f .github/actions/setup/compose.yml up -d
|
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||||
cd web && npm ci
|
cd web && npm i
|
||||||
- name: Generate config
|
- name: Generate config
|
||||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||||
shell: uv run python {0}
|
shell: uv run python {0}
|
||||||
|
|||||||
@@ -11,24 +11,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
restart: always
|
restart: always
|
||||||
s3:
|
redis:
|
||||||
container_name: s3
|
image: docker.io/library/redis:7
|
||||||
image: docker.io/zenko/cloudserver
|
|
||||||
environment:
|
|
||||||
REMOTE_MANAGEMENT_DISABLE: "1"
|
|
||||||
SCALITY_ACCESS_KEY_ID: accessKey1
|
|
||||||
SCALITY_SECRET_ACCESS_KEY: secretKey1
|
|
||||||
ports:
|
ports:
|
||||||
- 8020:8000
|
- 6379:6379
|
||||||
volumes:
|
|
||||||
- s3-data:/usr/src/app/localData
|
|
||||||
- s3-metadata:/usr/src/app/localMetadata
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
driver: local
|
driver: local
|
||||||
s3-data:
|
|
||||||
driver: local
|
|
||||||
s3-metadata:
|
|
||||||
driver: local
|
|
||||||
8
.github/actions/test-results/action.yml
vendored
8
.github/actions/test-results/action.yml
vendored
@@ -8,19 +8,19 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||||
with:
|
with:
|
||||||
flags: ${{ inputs.flags }}
|
flags: ${{ inputs.flags }}
|
||||||
use_oidc: true
|
use_oidc: true
|
||||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
|
||||||
with:
|
with:
|
||||||
flags: ${{ inputs.flags }}
|
flags: ${{ inputs.flags }}
|
||||||
|
file: unittest.xml
|
||||||
use_oidc: true
|
use_oidc: true
|
||||||
report_type: test_results
|
|
||||||
- name: PostgreSQL Logs
|
- name: PostgreSQL Logs
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ $RUNNER_DEBUG == '1' ]]; then
|
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
|
||||||
docker stop setup-postgresql-1
|
docker stop setup-postgresql-1
|
||||||
echo "::group::PostgreSQL Logs"
|
echo "::group::PostgreSQL Logs"
|
||||||
docker logs setup-postgresql-1
|
docker logs setup-postgresql-1
|
||||||
|
|||||||
1
.github/codespell-dictionary.txt
vendored
Normal file
1
.github/codespell-dictionary.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
authentic->authentik
|
||||||
32
.github/codespell-words.txt
vendored
Normal file
32
.github/codespell-words.txt
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
akadmin
|
||||||
|
asgi
|
||||||
|
assertIn
|
||||||
|
authentik
|
||||||
|
authn
|
||||||
|
crate
|
||||||
|
docstrings
|
||||||
|
entra
|
||||||
|
goauthentik
|
||||||
|
gunicorn
|
||||||
|
hass
|
||||||
|
jwe
|
||||||
|
jwks
|
||||||
|
keypair
|
||||||
|
keypairs
|
||||||
|
kubernetes
|
||||||
|
oidc
|
||||||
|
ontext
|
||||||
|
openid
|
||||||
|
passwordless
|
||||||
|
plex
|
||||||
|
saml
|
||||||
|
scim
|
||||||
|
singed
|
||||||
|
slo
|
||||||
|
sso
|
||||||
|
totp
|
||||||
|
traefik
|
||||||
|
# https://github.com/codespell-project/codespell/issues/1224
|
||||||
|
upToDate
|
||||||
|
warmup
|
||||||
|
webauthn
|
||||||
128
.github/dependabot.yml
vendored
128
.github/dependabot.yml
vendored
@@ -1,7 +1,5 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
#region Github Actions
|
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directories:
|
directories:
|
||||||
- /
|
- /
|
||||||
@@ -20,11 +18,6 @@ updates:
|
|||||||
prefix: "ci:"
|
prefix: "ci:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Golang
|
|
||||||
|
|
||||||
- package-ecosystem: gomod
|
- package-ecosystem: gomod
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
@@ -35,89 +28,11 @@ updates:
|
|||||||
prefix: "core:"
|
prefix: "core:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Rust
|
|
||||||
|
|
||||||
- package-ecosystem: rust-toolchain
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: daily
|
|
||||||
time: "04:00"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
commit-message:
|
|
||||||
prefix: "core:"
|
|
||||||
labels:
|
|
||||||
- dependencies
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Web
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directories:
|
directories:
|
||||||
- "/"
|
|
||||||
- "/web"
|
- "/web"
|
||||||
- "/web/packages/*"
|
- "/web/packages/sfe"
|
||||||
schedule:
|
- "/web/packages/core"
|
||||||
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:
|
|
||||||
- "/packages/esbuild-plugin-live-reload"
|
- "/packages/esbuild-plugin-live-reload"
|
||||||
- "/packages/prettier-config"
|
- "/packages/prettier-config"
|
||||||
- "/packages/tsconfig"
|
- "/packages/tsconfig"
|
||||||
@@ -130,11 +45,12 @@ updates:
|
|||||||
- dependencies
|
- dependencies
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "core, web:"
|
prefix: "web:"
|
||||||
groups:
|
groups:
|
||||||
sentry:
|
sentry:
|
||||||
patterns:
|
patterns:
|
||||||
- "@sentry/*"
|
- "@sentry/*"
|
||||||
|
- "@spotlightjs/*"
|
||||||
babel:
|
babel:
|
||||||
patterns:
|
patterns:
|
||||||
- "@babel/*"
|
- "@babel/*"
|
||||||
@@ -150,12 +66,10 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "@storybook/*"
|
- "@storybook/*"
|
||||||
- "*storybook*"
|
- "*storybook*"
|
||||||
bundler:
|
esbuild:
|
||||||
patterns:
|
patterns:
|
||||||
- "@esbuild/*"
|
- "@esbuild/*"
|
||||||
- "esbuild*"
|
- "esbuild*"
|
||||||
- "@vitest/*"
|
|
||||||
- "vitest"
|
|
||||||
rollup:
|
rollup:
|
||||||
patterns:
|
patterns:
|
||||||
- "@rollup/*"
|
- "@rollup/*"
|
||||||
@@ -165,6 +79,9 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "@swc/*"
|
- "@swc/*"
|
||||||
- "swc-*"
|
- "swc-*"
|
||||||
|
wdio:
|
||||||
|
patterns:
|
||||||
|
- "@wdio/*"
|
||||||
goauthentik:
|
goauthentik:
|
||||||
patterns:
|
patterns:
|
||||||
- "@goauthentik/*"
|
- "@goauthentik/*"
|
||||||
@@ -174,11 +91,6 @@ updates:
|
|||||||
- "react-dom"
|
- "react-dom"
|
||||||
- "@types/react"
|
- "@types/react"
|
||||||
- "@types/react-dom"
|
- "@types/react-dom"
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
# #region Documentation
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/website"
|
directory: "/website"
|
||||||
schedule:
|
schedule:
|
||||||
@@ -193,7 +105,6 @@ updates:
|
|||||||
docusaurus:
|
docusaurus:
|
||||||
patterns:
|
patterns:
|
||||||
- "@docusaurus/*"
|
- "@docusaurus/*"
|
||||||
- "@goauthentik/docusaurus-config"
|
|
||||||
build:
|
build:
|
||||||
patterns:
|
patterns:
|
||||||
- "@swc/*"
|
- "@swc/*"
|
||||||
@@ -202,9 +113,7 @@ updates:
|
|||||||
- "@rspack/binding*"
|
- "@rspack/binding*"
|
||||||
goauthentik:
|
goauthentik:
|
||||||
patterns:
|
patterns:
|
||||||
- "@goauthentik/eslint-config"
|
- "@goauthentik/*"
|
||||||
- "@goauthentik/prettier-config"
|
|
||||||
- "@goauthentik/tsconfig"
|
|
||||||
eslint:
|
eslint:
|
||||||
patterns:
|
patterns:
|
||||||
- "@eslint/*"
|
- "@eslint/*"
|
||||||
@@ -212,11 +121,6 @@ updates:
|
|||||||
- "eslint-*"
|
- "eslint-*"
|
||||||
- "eslint"
|
- "eslint"
|
||||||
- "typescript-eslint"
|
- "typescript-eslint"
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
# AWS Lifecycle
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/lifecycle/aws"
|
directory: "/lifecycle/aws"
|
||||||
schedule:
|
schedule:
|
||||||
@@ -227,11 +131,6 @@ updates:
|
|||||||
prefix: "lifecycle/aws:"
|
prefix: "lifecycle/aws:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Python
|
|
||||||
|
|
||||||
- package-ecosystem: uv
|
- package-ecosystem: uv
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
@@ -242,14 +141,9 @@ updates:
|
|||||||
prefix: "core:"
|
prefix: "core:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Docker
|
|
||||||
|
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directories:
|
directories:
|
||||||
- /lifecycle/container
|
- /
|
||||||
- /website
|
- /website
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: daily
|
||||||
@@ -272,5 +166,3 @@ updates:
|
|||||||
prefix: "core:"
|
prefix: "core:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|||||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -2,10 +2,6 @@
|
|||||||
👋 Hi there! Welcome.
|
👋 Hi there! Welcome.
|
||||||
|
|
||||||
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
|
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
|
## Details
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ jobs:
|
|||||||
# Needed for checkout
|
# Needed for checkout
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -56,35 +56,37 @@ jobs:
|
|||||||
release: ${{ inputs.release }}
|
release: ${{ inputs.release }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ inputs.registry_dockerhub }}
|
if: ${{ inputs.registry_dockerhub }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: ${{ inputs.registry_ghcr }}
|
if: ${{ inputs.registry_ghcr }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- name: make empty clients
|
||||||
|
if: ${{ inputs.release }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ./gen-ts-api
|
||||||
|
mkdir -p ./gen-go-api
|
||||||
|
- name: Setup node
|
||||||
|
if: ${{ !inputs.release }}
|
||||||
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- name: generate ts client
|
||||||
with:
|
if: ${{ !inputs.release }}
|
||||||
go-version-file: "go.mod"
|
run: make gen-client-ts
|
||||||
- name: Generate API Clients
|
|
||||||
run: |
|
|
||||||
make gen-client-ts
|
|
||||||
make gen-client-go
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
id: push
|
id: push
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: lifecycle/container/Dockerfile
|
|
||||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
secrets: |
|
secrets: |
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
@@ -95,7 +97,7 @@ jobs:
|
|||||||
platforms: linux/${{ inputs.image_arch }}
|
platforms: linux/${{ inputs.image_arch }}
|
||||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
||||||
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
12
.github/workflows/_reusable-docker-build.yml
vendored
12
.github/workflows/_reusable-docker-build.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||||
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -79,25 +79,25 @@ jobs:
|
|||||||
image-name: ${{ inputs.image_name }}
|
image-name: ${{ inputs.image_name }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ inputs.registry_dockerhub }}
|
if: ${{ inputs.registry_dockerhub }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: ${{ inputs.registry_ghcr }}
|
if: ${{ inputs.registry_ghcr }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: int128/docker-manifest-create-action@8aac06098a12365ccdf99372dcfb453ccce8a0b0 # v2
|
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # v2
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
tags: ${{ matrix.tag }}
|
tags: ${{ matrix.tag }}
|
||||||
sources: |
|
sources: |
|
||||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
|
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
|
||||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
|
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||||
|
|||||||
8
.github/workflows/api-ts-publish.yml
vendored
8
.github/workflows/api-ts-publish.yml
vendored
@@ -18,14 +18,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
|||||||
16
.github/workflows/ci-api-docs.yml
vendored
16
.github/workflows/ci-api-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
command:
|
command:
|
||||||
- prettier-check
|
- prettier-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
working-directory: website/
|
working-directory: website/
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -32,8 +32,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
- working-directory: website/
|
- working-directory: website/
|
||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ github.workspace }}/website/api/.docusaurus
|
${{ github.workspace }}/website/api/.docusaurus
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
run: npm run build -w api
|
run: npm run build -w api
|
||||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4
|
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
|
||||||
with:
|
with:
|
||||||
name: api-docs
|
name: api-docs
|
||||||
path: website/api/build
|
path: website/api/build
|
||||||
@@ -66,12 +66,12 @@ jobs:
|
|||||||
- lint
|
- lint
|
||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v5
|
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||||
with:
|
with:
|
||||||
name: api-docs
|
name: api-docs
|
||||||
path: website/api/build
|
path: website/api/build
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
4
.github/workflows/ci-aws-cfn.yml
vendored
4
.github/workflows/ci-aws-cfn.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
check-changes-applied:
|
check-changes-applied:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: lifecycle/aws/package.json
|
node-version-file: lifecycle/aws/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
2
.github/workflows/ci-docs-source.yml
vendored
2
.github/workflows/ci-docs-source.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: generate docs
|
- name: generate docs
|
||||||
|
|||||||
30
.github/workflows/ci-docs.yml
vendored
30
.github/workflows/ci-docs.yml
vendored
@@ -15,15 +15,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
command:
|
command:
|
||||||
- prettier-check
|
- prettier-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: website/
|
working-directory: website/
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -32,11 +30,10 @@ jobs:
|
|||||||
run: npm run ${{ matrix.command }}
|
run: npm run ${{ matrix.command }}
|
||||||
build-docs:
|
build-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -49,11 +46,10 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
build-integrations:
|
build-integrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -73,13 +69,13 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -89,14 +85,14 @@ jobs:
|
|||||||
image-name: ghcr.io/goauthentik/dev-docs
|
image-name: ghcr.io/goauthentik/dev-docs
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
with:
|
with:
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
tags: ${{ steps.ev.outputs.imageTags }}
|
||||||
file: website/Dockerfile
|
file: website/Dockerfile
|
||||||
@@ -105,7 +101,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
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' || '' }}
|
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
18
.github/workflows/ci-main-daily.yml
vendored
18
.github/workflows/ci-main-daily.yml
vendored
@@ -6,10 +6,6 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
# Every night at 3am
|
# Every night at 3am
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
# Needs to refer to itself
|
|
||||||
- .github/workflows/ci-main-daily.yml
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-container:
|
test-container:
|
||||||
@@ -19,14 +15,14 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
version:
|
version:
|
||||||
- docs
|
- docs
|
||||||
- version-2025-12
|
- version-2025-4
|
||||||
- version-2025-10
|
- version-2025-2
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- run: |
|
- run: |
|
||||||
current="$(pwd)"
|
current="$(pwd)"
|
||||||
dir="/tmp/authentik/${{ matrix.version }}"
|
dir="/tmp/authentik/${{ matrix.version }}"
|
||||||
mkdir -p "${dir}/lifecycle/container"
|
mkdir -p $dir
|
||||||
cd "${dir}"
|
cd $dir
|
||||||
wget "https://${{ matrix.version }}.goauthentik.io/docker-compose.yml" -O "${dir}/lifecycle/container/compose.yml"
|
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
|
||||||
"${current}/scripts/test_docker.sh"
|
${current}/scripts/test_docker.sh
|
||||||
|
|||||||
87
.github/workflows/ci-main.yml
vendored
87
.github/workflows/ci-main.yml
vendored
@@ -31,31 +31,21 @@ jobs:
|
|||||||
job:
|
job:
|
||||||
- bandit
|
- bandit
|
||||||
- black
|
- black
|
||||||
- spellcheck
|
- codespell
|
||||||
- pending-migrations
|
- pending-migrations
|
||||||
- ruff
|
- ruff
|
||||||
- mypy
|
- mypy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run job
|
- name: run job
|
||||||
run: uv run make ci-${{ matrix.job }}
|
run: uv run make ci-${{ matrix.job }}
|
||||||
test-gen-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- name: generate schema
|
|
||||||
run: make migrate gen-build
|
|
||||||
- name: ensure schema is up-to-date
|
|
||||||
run: git diff --exit-code -- schema.yml blueprints/schema.json
|
|
||||||
test-migrations:
|
test-migrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run migrations
|
- name: run migrations
|
||||||
@@ -81,7 +71,7 @@ jobs:
|
|||||||
- 18-alpine
|
- 18-alpine
|
||||||
run_id: [1, 2, 3, 4, 5]
|
run_id: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
@@ -94,7 +84,7 @@ jobs:
|
|||||||
# Current version family based on
|
# Current version family based on
|
||||||
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
|
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
|
||||||
if [[ -n $current_version_family ]]; then
|
if [[ -n $current_version_family ]]; then
|
||||||
prev_stable="version/${current_version_family}"
|
prev_stable=$current_version_family
|
||||||
fi
|
fi
|
||||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||||
git checkout ${prev_stable}
|
git checkout ${prev_stable}
|
||||||
@@ -146,7 +136,7 @@ jobs:
|
|||||||
- 18-alpine
|
- 18-alpine
|
||||||
run_id: [1, 2, 3, 4, 5]
|
run_id: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
@@ -166,11 +156,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Create k8s Kind Cluster
|
- name: Create k8s Kind Cluster
|
||||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
uv run coverage run manage.py test tests/integration
|
uv run coverage run manage.py test tests/integration
|
||||||
@@ -197,25 +187,21 @@ jobs:
|
|||||||
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||||
- name: ldap
|
- name: ldap
|
||||||
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
||||||
- name: ws-fed
|
|
||||||
glob: tests/e2e/test_provider_ws_fed*
|
|
||||||
- name: radius
|
- name: radius
|
||||||
glob: tests/e2e/test_provider_radius*
|
glob: tests/e2e/test_provider_radius*
|
||||||
- name: scim
|
- name: scim
|
||||||
glob: tests/e2e/test_source_scim*
|
glob: tests/e2e/test_source_scim*
|
||||||
- name: flows
|
- name: flows
|
||||||
glob: tests/e2e/test_flows*
|
glob: tests/e2e/test_flows*
|
||||||
- name: endpoints
|
|
||||||
glob: tests/e2e/test_endpoints_*
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Setup e2e env (chrome, etc)
|
- name: Setup e2e env (chrome, etc)
|
||||||
run: |
|
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
|
- id: cache-web
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
with:
|
with:
|
||||||
path: web/dist
|
path: web/dist
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||||
@@ -235,59 +221,10 @@ jobs:
|
|||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
with:
|
with:
|
||||||
flags: e2e
|
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
|
||||||
with:
|
|
||||||
name: conformance-certification-${{ matrix.job.name }}
|
|
||||||
path: tests/openid_conformance/exports/
|
|
||||||
ci-core-mark:
|
ci-core-mark:
|
||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
- lint
|
- lint
|
||||||
- test-gen-build
|
|
||||||
- test-migrations
|
- test-migrations
|
||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
@@ -323,7 +260,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
|
|||||||
30
.github/workflows/ci-outpost.yml
vendored
30
.github/workflows/ci-outpost.yml
vendored
@@ -21,8 +21,8 @@ jobs:
|
|||||||
lint-golint:
|
lint-golint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Prepare and generate API
|
- name: Prepare and generate API
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
|
uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v8
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: --timeout 5000s --verbose
|
args: --timeout 5000s --verbose
|
||||||
@@ -42,8 +42,8 @@ jobs:
|
|||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@@ -86,13 +86,13 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -111,10 +111,10 @@ jobs:
|
|||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
with:
|
with:
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
tags: ${{ steps.ev.outputs.imageTags }}
|
||||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
|
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) || '' }}
|
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||||
with:
|
with:
|
||||||
@@ -145,13 +145,13 @@ jobs:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
12
.github/workflows/ci-web.yml
vendored
12
.github/workflows/ci-web.yml
vendored
@@ -31,8 +31,8 @@ jobs:
|
|||||||
- command: lit-analyse
|
- command: lit-analyse
|
||||||
project: web
|
project: web
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: ${{ matrix.project }}/package.json
|
node-version-file: ${{ matrix.project }}/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -48,8 +48,8 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -76,8 +76,8 @@ jobs:
|
|||||||
- ci-web-mark
|
- ci-web-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
8
.github/workflows/gen-image-compress.yml
vendored
8
.github/workflows/gen-image-compress.yml
vendored
@@ -29,20 +29,20 @@ jobs:
|
|||||||
github.event.pull_request.head.repo.full_name == github.repository)
|
github.event.pull_request.head.repo.full_name == github.repository)
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- name: Compress images
|
- name: Compress images
|
||||||
id: compress
|
id: compress
|
||||||
uses: calibreapp/image-actions@d9c8ee5c3dc52ae4622c82ead88d658f4b16b65f # main
|
uses: calibreapp/image-actions@420075c115b26f8785e293c5bd5bef0911c506e5 # main
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- run: uv run ak update_webauthn_mds
|
- run: uv run ak update_webauthn_mds
|
||||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
|||||||
4
.github/workflows/gh-cherry-pick.yml
vendored
4
.github/workflows/gh-cherry-pick.yml
vendored
@@ -10,14 +10,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: app-token
|
- id: app-token
|
||||||
name: Generate app token
|
name: Generate app token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
if: ${{ env.GH_APP_ID != '' }}
|
if: ${{ env.GH_APP_ID != '' }}
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
env:
|
env:
|
||||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/gh-ghcr-retention.yml
vendored
2
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
|||||||
7
.github/workflows/packages-npm-publish.yml
vendored
7
.github/workflows/packages-npm-publish.yml
vendored
@@ -29,19 +29,18 @@ jobs:
|
|||||||
- packages/eslint-config
|
- packages/eslint-config
|
||||||
- packages/prettier-config
|
- packages/prettier-config
|
||||||
- packages/docusaurus-config
|
- packages/docusaurus-config
|
||||||
- packages/logger-js
|
|
||||||
- packages/esbuild-plugin-live-reload
|
- packages/esbuild-plugin-live-reload
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: ${{ matrix.package }}/package.json
|
node-version-file: ${{ matrix.package }}/package.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
${{ matrix.package }}/package.json
|
${{ matrix.package }}/package.json
|
||||||
|
|||||||
2
.github/workflows/qa-codeql.yml
vendored
2
.github/workflows/qa-codeql.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
language: ["go", "javascript", "python"]
|
language: ["go", "javascript", "python"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
2
.github/workflows/qa-semgrep.yml
vendored
2
.github/workflows/qa-semgrep.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
|||||||
image: semgrep/semgrep
|
image: semgrep/semgrep
|
||||||
if: (github.actor != 'dependabot[bot]')
|
if: (github.actor != 'dependabot[bot]')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- run: semgrep ci
|
- run: semgrep ci
|
||||||
|
|||||||
10
.github/workflows/release-branch-off.yml
vendored
10
.github/workflows/release-branch-off.yml
vendored
@@ -29,12 +29,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: app-token
|
- id: app-token
|
||||||
name: Generate app token
|
name: Generate app token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
@@ -57,12 +57,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
- name: Bump version
|
- name: Bump version
|
||||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
branch: release-bump-${{ inputs.next_version }}
|
branch: release-bump-${{ inputs.next_version }}
|
||||||
|
|||||||
2
.github/workflows/release-next-branch.yml
vendored
2
.github/workflows/release-next-branch.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: internal-production
|
environment: internal-production
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
80
.github/workflows/release-publish.yml
vendored
80
.github/workflows/release-publish.yml
vendored
@@ -31,11 +31,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -44,21 +44,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/docs
|
image-name: ghcr.io/goauthentik/docs
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
with:
|
with:
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
tags: ${{ steps.ev.outputs.imageTags }}
|
||||||
file: website/Dockerfile
|
file: website/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
if: true
|
if: true
|
||||||
with:
|
with:
|
||||||
@@ -83,19 +83,14 @@ jobs:
|
|||||||
- radius
|
- radius
|
||||||
- rac
|
- rac
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
|
||||||
with:
|
|
||||||
node-version-file: web/package.json
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -103,33 +98,33 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||||
- name: Generate API Clients
|
- name: make empty clients
|
||||||
run: |
|
run: |
|
||||||
make gen-client-ts
|
mkdir -p ./gen-ts-api
|
||||||
make gen-client-go
|
mkdir -p ./gen-go-api
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
id: push
|
id: push
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ github.ref }}
|
VERSION=${{ github.ref }}
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
tags: ${{ steps.ev.outputs.imageTags }}
|
||||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
id: attest
|
id: attest
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||||
@@ -151,26 +146,19 @@ jobs:
|
|||||||
goos: [linux, darwin]
|
goos: [linux, darwin]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
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
|
- name: Build web
|
||||||
working-directory: web/
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
|
npm ci
|
||||||
npm run build-proxy
|
npm run build-proxy
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
@@ -180,7 +168,7 @@ jobs:
|
|||||||
export CGO_ENABLED=0
|
export CGO_ENABLED=0
|
||||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||||
- name: Upload binaries to release
|
- name: Upload binaries to release
|
||||||
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2
|
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
@@ -198,8 +186,8 @@ jobs:
|
|||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
|
||||||
with:
|
with:
|
||||||
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
||||||
aws-region: ${{ env.AWS_REGION }}
|
aws-region: ${{ env.AWS_REGION }}
|
||||||
@@ -214,15 +202,15 @@ jobs:
|
|||||||
- build-outpost-binary
|
- build-outpost-binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: Run test suite in final docker images
|
- name: Run test suite in final docker images
|
||||||
run: |
|
run: |
|
||||||
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
|
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
|
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||||
docker compose -f lifecycle/container/compose.yml pull -q
|
docker compose pull -q
|
||||||
docker compose -f lifecycle/container/compose.yml up --no-start
|
docker compose up --no-start
|
||||||
docker compose -f lifecycle/container/compose.yml start postgresql
|
docker compose start postgresql
|
||||||
docker compose -f lifecycle/container/compose.yml run -u root server test-all
|
docker compose run -u root server test-all
|
||||||
sentry-release:
|
sentry-release:
|
||||||
needs:
|
needs:
|
||||||
- build-server
|
- build-server
|
||||||
@@ -230,7 +218,7 @@ jobs:
|
|||||||
- build-outpost-binary
|
- build-outpost-binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
@@ -244,7 +232,7 @@ jobs:
|
|||||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||||
docker cp ${container}:web/ .
|
docker cp ${container}:web/ .
|
||||||
- name: Create a Sentry.io release
|
- name: Create a Sentry.io release
|
||||||
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
|
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
33
.github/workflows/release-tag.yml
vendored
33
.github/workflows/release-tag.yml
vendored
@@ -49,14 +49,8 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Pre-release test
|
name: Pre-release test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
|
||||||
- check-inputs
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
|
||||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- run: make test-docker
|
- run: make test-docker
|
||||||
bump-authentik:
|
bump-authentik:
|
||||||
name: Bump authentik version
|
name: Bump authentik version
|
||||||
@@ -67,7 +61,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: app-token
|
- id: app-token
|
||||||
name: Generate app token
|
name: Generate app token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
@@ -76,7 +70,7 @@ jobs:
|
|||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
@@ -91,7 +85,6 @@ jobs:
|
|||||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
# 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.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 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 commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||||
git push --follow-tags
|
git push --follow-tags
|
||||||
@@ -115,7 +108,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: app-token
|
- id: app-token
|
||||||
name: Generate app token
|
name: Generate app token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
@@ -125,7 +118,7 @@ jobs:
|
|||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
repository: "${{ github.repository_owner }}/helm"
|
repository: "${{ github.repository_owner }}/helm"
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
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
|
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||||
./scripts/helm-docs.sh
|
./scripts/helm-docs.sh
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
with:
|
with:
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
branch: bump-${{ inputs.version }}
|
branch: bump-${{ inputs.version }}
|
||||||
@@ -157,7 +150,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: app-token
|
- id: app-token
|
||||||
name: Generate app token
|
name: Generate app token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
@@ -167,7 +160,7 @@ jobs:
|
|||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
with:
|
with:
|
||||||
repository: "${{ github.repository_owner }}/version"
|
repository: "${{ github.repository_owner }}/version"
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
@@ -175,28 +168,24 @@ jobs:
|
|||||||
if: "${{ inputs.release_reason == 'feature' }}"
|
if: "${{ inputs.release_reason == 'feature' }}"
|
||||||
run: |
|
run: |
|
||||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||||
reason="${{ inputs.release_reason }}"
|
|
||||||
jq \
|
jq \
|
||||||
--arg version "${{ inputs.version }}" \
|
--arg version "${{ inputs.version }}" \
|
||||||
--arg changelog "See ${changelog_url}" \
|
--arg changelog "See ${changelog_url}" \
|
||||||
--arg changelog_url "${changelog_url}" \
|
--arg changelog_url "${changelog_url}" \
|
||||||
--arg reason "${reason}" \
|
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
|
|
||||||
mv version.new.json version.json
|
mv version.new.json version.json
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
if: "${{ inputs.release_reason != 'feature' }}"
|
if: "${{ inputs.release_reason != 'feature' }}"
|
||||||
run: |
|
run: |
|
||||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
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 \
|
jq \
|
||||||
--arg version "${{ inputs.version }}" \
|
--arg version "${{ inputs.version }}" \
|
||||||
--arg changelog "See ${changelog_url}" \
|
--arg changelog "See ${changelog_url}" \
|
||||||
--arg changelog_url "${changelog_url}" \
|
--arg changelog_url "${changelog_url}" \
|
||||||
--arg reason "${reason}" \
|
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
|
|
||||||
mv version.new.json version.json
|
mv version.new.json version.json
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
with:
|
with:
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
branch: bump-${{ inputs.version }}
|
branch: bump-${{ inputs.version }}
|
||||||
|
|||||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||||
days-before-stale: 60
|
days-before-stale: 60
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
make web-check-compile
|
make web-check-compile
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
branch: extract-compile-backend-translation
|
branch: extract-compile-backend-translation
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -15,9 +15,6 @@ media
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
.cspellcache
|
|
||||||
cspell-report.*
|
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
# in your Git repository. Update and uncomment the following line accordingly.
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
# <django-project-name>/staticfiles/
|
# <django-project-name>/staticfiles/
|
||||||
@@ -214,5 +211,4 @@ source_docs/
|
|||||||
/vendor/
|
/vendor/
|
||||||
|
|
||||||
### Docker ###
|
### Docker ###
|
||||||
tests/openid_conformance/exports/*.zip
|
docker-compose.override.yml
|
||||||
compose.override.yml
|
|
||||||
|
|||||||
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@@ -11,13 +11,6 @@
|
|||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
|
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
|
||||||
},
|
},
|
||||||
"[xml]": {
|
|
||||||
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
|
|
||||||
},
|
|
||||||
"files.associations": {
|
|
||||||
// The built-in "ignore" language gives us enough syntax highlighting to make these files readable.
|
|
||||||
"**/dictionaries/*.txt": "ignore"
|
|
||||||
},
|
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
"todo-tree.tree.showBadges": true,
|
"todo-tree.tree.showBadges": true,
|
||||||
"yaml.customTags": [
|
"yaml.customTags": [
|
||||||
@@ -53,9 +46,13 @@
|
|||||||
"ignoreCase": false
|
"ignoreCase": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"go.testFlags": ["-count=1"],
|
"go.testFlags": [
|
||||||
|
"-count=1"
|
||||||
|
],
|
||||||
"go.testEnvVars": {
|
"go.testEnvVars": {
|
||||||
"WORKSPACE_DIR": "${workspaceFolder}"
|
"WORKSPACE_DIR": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
|
"github-actions.workflows.pinned.workflows": [
|
||||||
|
".github/workflows/ci-main.yml"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
11
CODEOWNERS
11
CODEOWNERS
@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
|
|||||||
# Infrastructure
|
# Infrastructure
|
||||||
.github/ @goauthentik/infrastructure
|
.github/ @goauthentik/infrastructure
|
||||||
lifecycle/aws/ @goauthentik/infrastructure
|
lifecycle/aws/ @goauthentik/infrastructure
|
||||||
lifecycle/container/ @goauthentik/infrastructure
|
Dockerfile @goauthentik/infrastructure
|
||||||
|
*Dockerfile @goauthentik/infrastructure
|
||||||
.dockerignore @goauthentik/infrastructure
|
.dockerignore @goauthentik/infrastructure
|
||||||
|
docker-compose.yml @goauthentik/infrastructure
|
||||||
Makefile @goauthentik/infrastructure
|
Makefile @goauthentik/infrastructure
|
||||||
.editorconfig @goauthentik/infrastructure
|
.editorconfig @goauthentik/infrastructure
|
||||||
CODEOWNERS @goauthentik/infrastructure
|
CODEOWNERS @goauthentik/infrastructure
|
||||||
@@ -26,20 +28,15 @@ packages/django-channels-postgres @goauthentik/backend
|
|||||||
packages/django-postgres-cache @goauthentik/backend
|
packages/django-postgres-cache @goauthentik/backend
|
||||||
packages/django-dramatiq-postgres @goauthentik/backend
|
packages/django-dramatiq-postgres @goauthentik/backend
|
||||||
# Web packages
|
# 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/docusaurus-config @goauthentik/frontend
|
||||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||||
packages/eslint-config @goauthentik/frontend
|
packages/eslint-config @goauthentik/frontend
|
||||||
packages/prettier-config @goauthentik/frontend
|
packages/prettier-config @goauthentik/frontend
|
||||||
packages/logger-js @goauthentik/frontend
|
|
||||||
packages/tsconfig @goauthentik/frontend
|
packages/tsconfig @goauthentik/frontend
|
||||||
# Web
|
# Web
|
||||||
web/ @goauthentik/frontend
|
web/ @goauthentik/frontend
|
||||||
# Locale
|
# Locale
|
||||||
/locale/ @goauthentik/backend @goauthentik/frontend
|
locale/ @goauthentik/backend @goauthentik/frontend
|
||||||
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||||
# Docs
|
# Docs
|
||||||
website/ @goauthentik/docs
|
website/ @goauthentik/docs
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
|||||||
npm run build:sfe
|
npm run build:sfe
|
||||||
|
|
||||||
# Stage 2: Build go proxy
|
# Stage 2: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.4-trixie@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS go-builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
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 \
|
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/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 \
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
go mod download
|
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
|
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||||
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
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 \
|
--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 && \
|
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}" \
|
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"
|
/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
|
# Stage 4: Download uv
|
||||||
FROM ghcr.io/astral-sh/uv:0.10.9@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 AS uv
|
FROM ghcr.io/astral-sh/uv:0.9.14@sha256:fef8e5fb8809f4b57069e919ffcd1529c92b432a2c8d8ad1768087b0b018d840 AS uv
|
||||||
# Stage 5: Base python image
|
# Stage 5: Base python image
|
||||||
FROM ghcr.io/goauthentik/fips-python:3.14.3-slim-trixie-fips@sha256:b481db20729091baf12e3641ae49c9d7240902d48d4454658f2cdeb2828b5709 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" \
|
ENV VENV_PATH="/ak-root/.venv" \
|
||||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||||
@@ -104,7 +102,6 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa
|
|||||||
ENV PATH="/root/.cargo/bin:$PATH"
|
ENV PATH="/root/.cargo/bin:$PATH"
|
||||||
|
|
||||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
# Required for installing pip packages
|
# Required for installing pip packages
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -117,27 +114,21 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
|||||||
# postgresql
|
# postgresql
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
# python-kadmin-rs
|
# python-kadmin-rs
|
||||||
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
|
clang libkrb5-dev sccache \
|
||||||
# xmlsec
|
# xmlsec
|
||||||
libltdl-dev && \
|
libltdl-dev && \
|
||||||
export RUST_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" && \
|
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" && \
|
|
||||||
rustup default "${RUST_TOOLCHAIN}" && \
|
|
||||||
rustc --version && \
|
|
||||||
cargo --version
|
|
||||||
|
|
||||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
||||||
# https://github.com/rust-lang/rustup/issues/2949
|
# https://github.com/rust-lang/rustup/issues/2949
|
||||||
# Fixes issues where the rust version in the build cache is older than latest
|
# Fixes issues where the rust version in the build cache is older than latest
|
||||||
# and rustup tries to update it, which fails
|
# and rustup tries to update it, which fails
|
||||||
RUSTUP_PERMIT_COPY_RENAME="1"
|
RUSTUP_PERMIT_COPY_RENAME="true"
|
||||||
|
|
||||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||||
--mount=type=bind,target=packages,src=packages \
|
--mount=type=bind,target=packages,src=packages \
|
||||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
--mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
|
|
||||||
RUSTUP_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" \
|
|
||||||
uv sync --frozen --no-install-project --no-dev
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
# Stage 7: Run
|
# Stage 7: Run
|
||||||
@@ -165,22 +156,17 @@ WORKDIR /
|
|||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get upgrade -y && \
|
apt-get upgrade -y && \
|
||||||
# Required for runtime
|
# Required for runtime
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
|
||||||
libpq5 libmaxminddb0 ca-certificates \
|
|
||||||
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
|
|
||||||
heimdal-multidev libkadm5clnt7t64-heimdal \
|
|
||||||
libltdl7 libxslt1.1 && \
|
|
||||||
# Required for bootstrap & healtcheck
|
# Required for bootstrap & healtcheck
|
||||||
apt-get install -y --no-install-recommends runit && \
|
apt-get install -y --no-install-recommends runit && \
|
||||||
pip3 install --no-cache-dir --upgrade pip && \
|
pip3 install --no-cache-dir --upgrade pip && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||||
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
||||||
mkdir -p /certs /data /media /blueprints && \
|
mkdir -p /certs /media /blueprints && \
|
||||||
ln -s /media /data/media && \
|
|
||||||
mkdir -p /authentik/.ssh && \
|
mkdir -p /authentik/.ssh && \
|
||||||
mkdir -p /ak-root && \
|
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 ./authentik/ /authentik
|
||||||
COPY ./pyproject.toml /
|
COPY ./pyproject.toml /
|
||||||
180
Makefile
180
Makefile
@@ -5,56 +5,32 @@ SHELL := /usr/bin/env bash
|
|||||||
PWD = $(shell pwd)
|
PWD = $(shell pwd)
|
||||||
UID = $(shell id -u)
|
UID = $(shell id -u)
|
||||||
GID = $(shell id -g)
|
GID = $(shell id -g)
|
||||||
|
NPM_VERSION = $(shell python -m scripts.generate_semver)
|
||||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
||||||
DOCKER_IMAGE ?= "authentik:test"
|
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_TS = gen-ts-api
|
||||||
GEN_API_PY = gen-py-api
|
GEN_API_PY = gen-py-api
|
||||||
GEN_API_GO = gen-go-api
|
GEN_API_GO = gen-go-api
|
||||||
|
|
||||||
BREW_LDFLAGS :=
|
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
|
||||||
BREW_CPPFLAGS :=
|
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
|
||||||
BREW_PKG_CONFIG_PATH :=
|
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
|
||||||
|
|
||||||
UV := uv
|
|
||||||
|
|
||||||
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
|
# 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
|
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
|
||||||
ifeq ($(UNAME_S),Darwin)
|
# These functions are only evaluated when called in specific targets
|
||||||
# Only add for brew users who installed libxmlsec1
|
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
|
||||||
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
|
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
|
||||||
ifdef BREW_EXISTS
|
|
||||||
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
|
|
||||||
ifdef LIBXML2_EXISTS
|
|
||||||
_xml_pref := $(shell brew --prefix libxml2)
|
|
||||||
BREW_LDFLAGS += -L${_xml_pref}/lib
|
|
||||||
BREW_CPPFLAGS += -I${_xml_pref}/include
|
|
||||||
BREW_PKG_CONFIG_PATH = ${_xml_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
|
||||||
endif
|
|
||||||
KRB5_EXISTS := $(shell brew list krb5 2> /dev/null)
|
|
||||||
ifdef KRB5_EXISTS
|
|
||||||
_krb5_pref := $(shell brew --prefix krb5)
|
|
||||||
BREW_LDFLAGS += -L${_krb5_pref}/lib
|
|
||||||
BREW_CPPFLAGS += -I${_krb5_pref}/include
|
|
||||||
BREW_PKG_CONFIG_PATH = ${_krb5_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
|
||||||
endif
|
|
||||||
UV := LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv
|
|
||||||
endif
|
|
||||||
endif
|
|
||||||
|
|
||||||
NPM_VERSION :=
|
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
|
||||||
UV_EXISTS := $(shell command -v uv 2> /dev/null)
|
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
|
||||||
ifdef UV_EXISTS
|
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
||||||
NPM_VERSION := $(shell $(UV) run python -m scripts.generate_semver)
|
|
||||||
else
|
KRB_PATH =
|
||||||
NPM_VERSION = $(shell python -m scripts.generate_semver)
|
|
||||||
|
ifneq ($(KRB5_EXISTS),)
|
||||||
|
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
|
||||||
endif
|
endif
|
||||||
|
|
||||||
all: lint-fix lint gen web test ## Lint, build, and test everything
|
all: lint-fix lint gen web test ## Lint, build, and test everything
|
||||||
@@ -73,46 +49,47 @@ go-test:
|
|||||||
go test -timeout 0 -v -race -cover ./...
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
test: ## Run the server tests and produce a coverage report (locally)
|
test: ## Run the server tests and produce a coverage report (locally)
|
||||||
$(UV) run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
||||||
$(UV) run coverage html
|
uv run coverage html
|
||||||
$(UV) run coverage report
|
uv run coverage report
|
||||||
|
|
||||||
lint-fix: lint-spellcheck ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||||
$(UV) run black $(PY_SOURCES)
|
uv run black $(PY_SOURCES)
|
||||||
$(UV) run ruff check --fix $(PY_SOURCES)
|
uv run ruff check --fix $(PY_SOURCES)
|
||||||
|
|
||||||
lint-spellcheck: ## Reports spelling errors.
|
lint-codespell: ## Reports spelling errors.
|
||||||
npm run lint:spellcheck
|
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
|
golangci-lint run -v
|
||||||
|
|
||||||
core-install:
|
core-install:
|
||||||
ifdef ($(BREW_EXISTS))
|
ifneq ($(LIBXML2_EXISTS),)
|
||||||
# Clear cache to ensure fresh compilation
|
# Clear cache to ensure fresh compilation
|
||||||
$(UV) cache clean
|
uv cache clean
|
||||||
# Force compilation from source for lxml and xmlsec with correct environment
|
# Force compilation from source for lxml and xmlsec with correct environment
|
||||||
$(UV) sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
||||||
else
|
else
|
||||||
$(UV) sync --frozen
|
uv sync --frozen
|
||||||
endif
|
endif
|
||||||
|
|
||||||
migrate: ## Run the Authentik Django server's migrations
|
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
|
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
|
||||||
|
|
||||||
aws-cfn:
|
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
|
run-server: ## Run the main authentik server process
|
||||||
$(UV) run ak server
|
uv run ak server
|
||||||
|
|
||||||
run-worker: ## Run the main authentik worker process
|
run-worker: ## Run the main authentik worker process
|
||||||
$(UV) run ak worker
|
uv run ak worker
|
||||||
|
|
||||||
core-i18n-extract:
|
core-i18n-extract:
|
||||||
$(UV) run ak makemessages \
|
uv run ak makemessages \
|
||||||
--add-location file \
|
--add-location file \
|
||||||
--no-obsolete \
|
--no-obsolete \
|
||||||
--ignore web \
|
--ignore web \
|
||||||
@@ -125,17 +102,11 @@ core-i18n-extract:
|
|||||||
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
|
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
|
||||||
|
|
||||||
dev-drop-db:
|
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
|
dropdb -U ${pg_user} -h ${pg_host} ${pg_name} || true
|
||||||
# Also remove the test-db if it exists
|
# Also remove the test-db if it exists
|
||||||
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
|
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
|
||||||
|
|
||||||
dev-create-db:
|
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}
|
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.
|
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
|
||||||
@@ -148,11 +119,11 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
|||||||
ifndef version
|
ifndef version
|
||||||
$(error Usage: make bump version=20xx.xx.xx )
|
$(error Usage: make bump version=20xx.xx.xx )
|
||||||
endif
|
endif
|
||||||
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
|
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||||
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' ${PWD}/pyproject.toml
|
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' ${PWD}/authentik/__init__.py
|
|
||||||
$(MAKE) gen-build gen-compose aws-cfn
|
$(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
|
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
@@ -163,35 +134,29 @@ gen-build: ## Extract the schema from the database
|
|||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true \
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_TENANTS__ENABLED=true \
|
||||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=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:
|
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 version
|
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
||||||
# These are best-effort guesses based on commit messages
|
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
|
||||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
|
|
||||||
$(eval current_commit := $(shell git rev-parse HEAD))
|
|
||||||
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${current_commit} > merged_to_current
|
|
||||||
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${last_version} > merged_to_last
|
|
||||||
grep -Eo 'cherry-pick (#\d+)' merged_to_last | cut -d ' ' -f 2 | sed 's/.*/(&)$$/' > cherry_picked_to_last
|
|
||||||
grep -vf cherry_picked_to_last merged_to_current | sort > changelog.md
|
|
||||||
rm merged_to_current
|
|
||||||
rm merged_to_last
|
|
||||||
rm cherry_picked_to_last
|
|
||||||
npx prettier --write changelog.md
|
npx prettier --write changelog.md
|
||||||
|
|
||||||
gen-diff: ## (Release) generate the changelog diff between the current schema and the last version
|
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
|
||||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
|
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
|
||||||
git show ${last_version}:schema.yml > schema-old.yml
|
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||||
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
|
|
||||||
--markdown \
|
--markdown \
|
||||||
/local/diff.md \
|
/local/diff.md \
|
||||||
/local/schema-old.yml \
|
/local/schema-old.yml \
|
||||||
/local/schema.yml
|
/local/schema.yml
|
||||||
rm schema-old.yml
|
rm schema-old.yml
|
||||||
$(SED_INPLACE) 's/{/{/g' diff.md
|
sed -i 's/{/{/g' diff.md
|
||||||
$(SED_INPLACE) 's/}/}/g' diff.md
|
sed -i 's/}/}/g' diff.md
|
||||||
npx prettier --write diff.md
|
npx prettier --write diff.md
|
||||||
|
|
||||||
gen-clean-ts: ## Remove generated API client for TypeScript
|
gen-clean-ts: ## Remove generated API client for TypeScript
|
||||||
@@ -207,7 +172,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-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
|
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 \
|
generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g typescript-fetch \
|
-g typescript-fetch \
|
||||||
@@ -223,19 +188,28 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
|||||||
|
|
||||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||||
mkdir -p ${PWD}/${GEN_API_PY}
|
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}
|
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}
|
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||||
|
|
||||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
gen-client-go: ## Build and install the authentik API for Golang
|
||||||
mkdir -p ${PWD}/${GEN_API_GO}
|
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}
|
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||||
|
else
|
||||||
|
cd ${PWD}/${GEN_API_GO} && git reset --hard
|
||||||
|
cd ${PWD}/${GEN_API_GO} && git pull
|
||||||
|
endif
|
||||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
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}
|
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
||||||
|
|
||||||
gen-dev-config: ## Generate a local development config file
|
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
|
gen: gen-build gen-client-ts
|
||||||
|
|
||||||
@@ -286,7 +260,7 @@ docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Au
|
|||||||
docs-install:
|
docs-install:
|
||||||
npm ci --prefix website
|
npm ci --prefix website
|
||||||
|
|
||||||
docs-lint-fix: lint-spellcheck
|
docs-lint-fix: lint-codespell
|
||||||
npm run --prefix website prettier
|
npm run --prefix website prettier
|
||||||
|
|
||||||
docs-build:
|
docs-build:
|
||||||
@@ -319,7 +293,7 @@ docs-api-clean: ## Clean generated API documentation
|
|||||||
|
|
||||||
docker: ## Build a docker image of the current source tree
|
docker: ## Build a docker image of the current source tree
|
||||||
mkdir -p ${GEN_API_TS}
|
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:
|
test-docker:
|
||||||
BUILD=true ${PWD}/scripts/test_docker.sh
|
BUILD=true ${PWD}/scripts/test_docker.sh
|
||||||
@@ -331,28 +305,28 @@ test-docker:
|
|||||||
# which makes the YAML File a lot smaller
|
# which makes the YAML File a lot smaller
|
||||||
|
|
||||||
ci--meta-debug:
|
ci--meta-debug:
|
||||||
$(UV) run python -V
|
python -V
|
||||||
node --version
|
node --version
|
||||||
|
|
||||||
ci-mypy: ci--meta-debug
|
ci-mypy: ci--meta-debug
|
||||||
$(UV) run mypy --strict $(PY_SOURCES)
|
uv run mypy --strict $(PY_SOURCES)
|
||||||
|
|
||||||
ci-black: ci--meta-debug
|
ci-black: ci--meta-debug
|
||||||
$(UV) run black --check $(PY_SOURCES)
|
uv run black --check $(PY_SOURCES)
|
||||||
|
|
||||||
ci-ruff: ci--meta-debug
|
ci-ruff: ci--meta-debug
|
||||||
$(UV) run ruff check $(PY_SOURCES)
|
uv run ruff check $(PY_SOURCES)
|
||||||
|
|
||||||
ci-spellcheck: ci--meta-debug
|
ci-codespell: ci--meta-debug
|
||||||
npm run lint:spellcheck
|
uv run codespell -s
|
||||||
|
|
||||||
ci-bandit: ci--meta-debug
|
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
|
ci-pending-migrations: ci--meta-debug
|
||||||
$(UV) run ak makemigrations --check
|
uv run ak makemigrations --check
|
||||||
|
|
||||||
ci-test: ci--meta-debug
|
ci-test: ci--meta-debug
|
||||||
$(UV) run coverage run manage.py test --keepdb authentik
|
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
||||||
$(UV) run coverage report
|
uv run coverage report
|
||||||
$(UV) run coverage xml
|
uv run coverage xml
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ---------- | ---------- |
|
| ---------- | ---------- |
|
||||||
| 2025.12.x | ✅ |
|
| 2025.8.x | ✅ |
|
||||||
| 2026.2.x | ✅ |
|
| 2025.10.x | ✅ |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
VERSION = "2026.5.0-rc1"
|
VERSION = "2025.12.0-rc1"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from authentik import authentik_full_version
|
from authentik import authentik_full_version
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.enterprise.license import LicenseKey
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.utils.reflection import get_env
|
from authentik.lib.utils.reflection import get_env
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
@@ -25,15 +26,6 @@ from authentik.outposts.models import Outpost
|
|||||||
from authentik.rbac.permissions import HasPermission
|
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):
|
class RuntimeDict(TypedDict):
|
||||||
"""Runtime information"""
|
"""Runtime information"""
|
||||||
|
|
||||||
@@ -88,7 +80,9 @@ class SystemInfoSerializer(PassiveSerializer):
|
|||||||
"architecture": platform.machine(),
|
"architecture": platform.machine(),
|
||||||
"authentik_version": authentik_full_version(),
|
"authentik_version": authentik_full_version(),
|
||||||
"environment": get_env(),
|
"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,
|
"openssl_version": OPENSSL_VERSION,
|
||||||
"platform": platform.platform(),
|
"platform": platform.platform(),
|
||||||
"python_version": python_version,
|
"python_version": python_version,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
def get_version_latest(self, _) -> str:
|
def get_version_latest(self, _) -> str:
|
||||||
"""Get latest version from cache"""
|
"""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()
|
return authentik_version()
|
||||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||||
if not version_in_cache: # pragma: no cover
|
if not version_in_cache: # pragma: no cover
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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 qualified 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))
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()}
|
|
||||||
@@ -1,255 +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"),
|
|
||||||
)
|
|
||||||
signature_version = CONFIG.get(
|
|
||||||
f"storage.{self.usage.value}.{self.name}.signature_version",
|
|
||||||
CONFIG.get(f"storage.{self.name}.signature_version", "s3v4"),
|
|
||||||
)
|
|
||||||
# Keep signature_version pass-through and let boto3/botocore handle it.
|
|
||||||
# In boto3's S3 configuration docs, `s3v4` (default) and deprecated `s3`
|
|
||||||
# are the documented values:
|
|
||||||
# https://github.com/boto/boto3/blob/791a3e8f36d83664a47b4281a0586b3546cef3ec/docs/source/guide/configuration.rst?plain=1#L398-L407
|
|
||||||
# Botocore also supports additional signer names, so we intentionally do
|
|
||||||
# not enforce a restricted allowlist here.
|
|
||||||
|
|
||||||
return self.session.client(
|
|
||||||
"s3",
|
|
||||||
endpoint_url=endpoint_url,
|
|
||||||
use_ssl=use_ssl,
|
|
||||||
region_name=region_name,
|
|
||||||
config=Config(
|
|
||||||
signature_version=signature_version, s3={"addressing_style": addressing_style}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"])
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
from unittest import skipUnless
|
|
||||||
|
|
||||||
from botocore.exceptions import UnsupportedSignatureVersionError
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
|
||||||
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)
|
|
||||||
|
|
||||||
def test_client_signature_version_default_v4(self):
|
|
||||||
"""Test S3 client defaults to v4 signature when not configured."""
|
|
||||||
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")
|
|
||||||
|
|
||||||
@CONFIG.patch("storage.s3.signature_version", "s3")
|
|
||||||
def test_client_signature_version_global_override(self):
|
|
||||||
"""Test S3 client respects globally configured signature version."""
|
|
||||||
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
|
|
||||||
|
|
||||||
@CONFIG.patch("storage.s3.signature_version", "s3v4")
|
|
||||||
@CONFIG.patch("storage.media.s3.signature_version", "s3")
|
|
||||||
def test_client_signature_version_media_override(self):
|
|
||||||
"""Test usage-specific signature version takes precedence over global."""
|
|
||||||
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
|
|
||||||
|
|
||||||
@CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
|
|
||||||
def test_client_signature_version_unsupported(self):
|
|
||||||
"""Test unsupported signature version raises botocore error."""
|
|
||||||
with self.assertRaises(UnsupportedSignatureVersionError):
|
|
||||||
self.media_s3_backend.file_url("test.png", use_cache=False)
|
|
||||||
|
|
||||||
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
|
|
||||||
def test_file_exists_true(self):
|
|
||||||
"""Test file_exists returns True for existing file"""
|
|
||||||
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")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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]
|
|
||||||
@@ -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]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""authentik files tests"""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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"),
|
|
||||||
]
|
|
||||||
@@ -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)])
|
|
||||||
@@ -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)")
|
|
||||||
)
|
|
||||||
@@ -13,10 +13,10 @@ from rest_framework.exceptions import AuthenticationFailed
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from structlog.stdlib import get_logger
|
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.middleware import CTX_AUTH_VIA
|
||||||
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
_tmp = Path(gettempdir())
|
_tmp = Path(gettempdir())
|
||||||
@@ -42,6 +42,68 @@ def validate_auth(header: bytes, format="bearer") -> str | None:
|
|||||||
return auth_credentials
|
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):
|
class IPCUser(AnonymousUser):
|
||||||
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
|
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
|
||||||
|
|
||||||
@@ -70,8 +132,13 @@ class IPCUser(AnonymousUser):
|
|||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
return True
|
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):
|
class TokenAuthentication(BaseAuthentication):
|
||||||
@@ -81,79 +148,12 @@ class TokenAuthentication(BaseAuthentication):
|
|||||||
"""Token-based authentication using HTTP Bearer authentication"""
|
"""Token-based authentication using HTTP Bearer authentication"""
|
||||||
auth = get_authorization_header(request)
|
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.
|
# None is only returned when the header isn't set.
|
||||||
if not user_ctx:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return user_ctx
|
return (user, None) # pragma: no cover
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TokenSchema(OpenApiAuthenticationExtension):
|
class TokenSchema(OpenApiAuthenticationExtension):
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -13,13 +13,6 @@ class Pagination(pagination.PageNumberPagination):
|
|||||||
page_query_param = "page"
|
page_query_param = "page"
|
||||||
page_size_query_param = "page_size"
|
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):
|
def get_paginated_response(self, data):
|
||||||
previous_page_number = 0
|
previous_page_number = 0
|
||||||
if self.page.has_previous():
|
if self.page.has_previous():
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def postprocess_schema_responses(
|
|||||||
def postprocess_schema_query_params(
|
def postprocess_schema_query_params(
|
||||||
result: dict[str, Any], generator: SchemaGenerator, **kwargs
|
result: dict[str, Any], generator: SchemaGenerator, **kwargs
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Optimize pagination parameters, instead of redeclaring parameters for each endpoint
|
"""Optimise pagination parameters, instead of redeclaring parameters for each endpoint
|
||||||
declare them globally and refer to them"""
|
declare them globally and refer to them"""
|
||||||
LOGGER.debug("Deduplicating query parameters")
|
LOGGER.debug("Deduplicating query parameters")
|
||||||
for path in result["paths"].values():
|
for path in result["paths"].values():
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
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.blueprints.tests import reconcile_app
|
||||||
from authentik.common.oauth.constants import SCOPE_AUTHENTIK_API
|
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||||
from authentik.core.models import Token, TokenIntents, UserTypes
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
@@ -25,24 +24,22 @@ class TestAPIAuth(TestCase):
|
|||||||
|
|
||||||
def test_invalid_type(self):
|
def test_invalid_type(self):
|
||||||
"""Test invalid type"""
|
"""Test invalid type"""
|
||||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"foo bar"))
|
self.assertIsNone(bearer_auth(b"foo bar"))
|
||||||
|
|
||||||
def test_invalid_empty(self):
|
def test_invalid_empty(self):
|
||||||
"""Test invalid type"""
|
"""Test invalid type"""
|
||||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"Bearer "))
|
self.assertIsNone(bearer_auth(b"Bearer "))
|
||||||
self.assertIsNone(TokenAuthentication().bearer_auth(b""))
|
self.assertIsNone(bearer_auth(b""))
|
||||||
|
|
||||||
def test_invalid_no_token(self):
|
def test_invalid_no_token(self):
|
||||||
"""Test invalid with no token"""
|
"""Test invalid with no token"""
|
||||||
auth = b64encode(b":abc").decode()
|
auth = b64encode(b":abc").decode()
|
||||||
self.assertIsNone(TokenAuthentication().bearer_auth(f"Basic :{auth}".encode()))
|
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
||||||
|
|
||||||
def test_bearer_valid(self):
|
def test_bearer_valid(self):
|
||||||
"""Test valid token"""
|
"""Test valid token"""
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
|
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(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||||
self.assertEqual(user, token.user)
|
|
||||||
self.assertEqual(token, token)
|
|
||||||
|
|
||||||
def test_bearer_valid_deactivated(self):
|
def test_bearer_valid_deactivated(self):
|
||||||
"""Test valid token"""
|
"""Test valid token"""
|
||||||
@@ -51,7 +48,7 @@ class TestAPIAuth(TestCase):
|
|||||||
user.save()
|
user.save()
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
|
bearer_auth(f"Bearer {token.key}".encode())
|
||||||
|
|
||||||
@reconcile_app("authentik_outposts")
|
@reconcile_app("authentik_outposts")
|
||||||
def test_managed_outpost_fail(self):
|
def test_managed_outpost_fail(self):
|
||||||
@@ -60,21 +57,20 @@ class TestAPIAuth(TestCase):
|
|||||||
outpost.user.delete()
|
outpost.user.delete()
|
||||||
outpost.delete()
|
outpost.delete()
|
||||||
with self.assertRaises(AuthenticationFailed):
|
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")
|
@reconcile_app("authentik_outposts")
|
||||||
def test_managed_outpost_success(self):
|
def test_managed_outpost_success(self):
|
||||||
"""Test managed outpost"""
|
"""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(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||||
self.assertEqual(outpost, Outpost.objects.filter(managed=MANAGED_OUTPOST).first())
|
|
||||||
|
|
||||||
def test_jwt_valid(self):
|
def test_jwt_valid(self):
|
||||||
"""Test valid JWT"""
|
"""Test valid JWT"""
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
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(),
|
user=create_test_admin_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
token=generate_id(),
|
token=generate_id(),
|
||||||
@@ -82,16 +78,14 @@ class TestAPIAuth(TestCase):
|
|||||||
_scope=SCOPE_AUTHENTIK_API,
|
_scope=SCOPE_AUTHENTIK_API,
|
||||||
_id_token=json.dumps({}),
|
_id_token=json.dumps({}),
|
||||||
)
|
)
|
||||||
user, token = TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||||
self.assertEqual(user, access.user)
|
|
||||||
self.assertEqual(token, access)
|
|
||||||
|
|
||||||
def test_jwt_missing_scope(self):
|
def test_jwt_missing_scope(self):
|
||||||
"""Test valid JWT"""
|
"""Test valid JWT"""
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
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(),
|
user=create_test_admin_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
token=generate_id(),
|
token=generate_id(),
|
||||||
@@ -100,12 +94,4 @@ class TestAPIAuth(TestCase):
|
|||||||
_id_token=json.dumps({}),
|
_id_token=json.dumps({}),
|
||||||
)
|
)
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
"""Schema generation tests"""
|
"""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 django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from yaml import safe_load
|
from yaml import safe_load
|
||||||
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaGeneration(APITestCase):
|
class TestSchemaGeneration(APITestCase):
|
||||||
"""Generic admin tests"""
|
"""Generic admin tests"""
|
||||||
@@ -28,17 +21,3 @@ class TestSchemaGeneration(APITestCase):
|
|||||||
reverse("authentik_api:schema-browser"),
|
reverse("authentik_api:schema-browser"),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
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())
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""core Configs API"""
|
"""core Configs API"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.dispatch import Signal
|
from django.dispatch import Signal
|
||||||
@@ -18,8 +20,6 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
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.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.context_processors.base import get_context_processors
|
from authentik.events.context_processors.base import get_context_processors
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
@@ -31,7 +31,6 @@ class Capabilities(models.TextChoices):
|
|||||||
"""Define capabilities which influence which APIs can/should be used"""
|
"""Define capabilities which influence which APIs can/should be used"""
|
||||||
|
|
||||||
CAN_SAVE_MEDIA = "can_save_media"
|
CAN_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_SAVE_REPORTS = "can_save_reports"
|
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
CAN_ASN = "can_asn"
|
CAN_ASN = "can_asn"
|
||||||
CAN_IMPERSONATE = "can_impersonate"
|
CAN_IMPERSONATE = "can_impersonate"
|
||||||
@@ -69,10 +68,13 @@ class ConfigView(APIView):
|
|||||||
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
|
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
|
||||||
"""Get all capabilities this server instance supports"""
|
"""Get all capabilities this server instance supports"""
|
||||||
caps = []
|
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)
|
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():
|
for processor in get_context_processors():
|
||||||
if cap := processor.capability():
|
if cap := processor.capability():
|
||||||
caps.append(cap)
|
caps.append(cap)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""authentik Blueprints app"""
|
"""authentik Blueprints app"""
|
||||||
|
|
||||||
import traceback
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from inspect import ismethod
|
from inspect import ismethod
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
|
||||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||||
from dramatiq.broker import get_broker
|
from dramatiq.broker import get_broker
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
@@ -46,21 +44,8 @@ class ManagedAppConfig(AppConfig):
|
|||||||
module_name = f"{self.name}.{rel_module}"
|
module_name = f"{self.name}.{rel_module}"
|
||||||
import_module(module_name)
|
import_module(module_name)
|
||||||
self.logger.info("Imported related module", module=module_name)
|
self.logger.info("Imported related module", module=module_name)
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError:
|
||||||
if settings.DEBUG:
|
pass
|
||||||
# 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
|
|
||||||
|
|
||||||
import_relative("checks")
|
import_relative("checks")
|
||||||
import_relative("tasks")
|
import_relative("tasks")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Generate JSON Schema for blueprints"""
|
"""Generate JSON Schema for blueprints"""
|
||||||
|
|
||||||
|
from json import dumps
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, no_translations
|
||||||
from django.db.models import Model, fields
|
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 drf_jsonschema_serializer.convert import converter, field_to_converter
|
||||||
from rest_framework.fields import Field, JSONField, UUIDField
|
from rest_framework.fields import Field, JSONField, UUIDField
|
||||||
from rest_framework.relations import PrimaryKeyRelatedField
|
from rest_framework.relations import PrimaryKeyRelatedField
|
||||||
@@ -31,19 +32,18 @@ class PrimaryKeyRelatedFieldConverter:
|
|||||||
def convert(self, field: PrimaryKeyRelatedField):
|
def convert(self, field: PrimaryKeyRelatedField):
|
||||||
model: Model = field.queryset.model
|
model: Model = field.queryset.model
|
||||||
pk_field = model._meta.pk
|
pk_field = model._meta.pk
|
||||||
if isinstance(pk_field, OneToOneField):
|
|
||||||
pk_field = pk_field.related_fields[0][1]
|
|
||||||
if isinstance(pk_field, fields.UUIDField):
|
if isinstance(pk_field, fields.UUIDField):
|
||||||
return {"type": "string", "format": "uuid"}
|
return {"type": "string", "format": "uuid"}
|
||||||
return {"type": "integer"}
|
return {"type": "integer"}
|
||||||
|
|
||||||
|
|
||||||
class SchemaBuilder:
|
class Command(BaseCommand):
|
||||||
"""Generate JSON Schema for blueprints"""
|
"""Generate JSON Schema for blueprints"""
|
||||||
|
|
||||||
schema: dict
|
schema: dict
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
self.schema = {
|
self.schema = {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema",
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||||
@@ -90,6 +90,16 @@ class SchemaBuilder:
|
|||||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
"$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
|
@staticmethod
|
||||||
def json_default(value: Any) -> Any:
|
def json_default(value: Any) -> Any:
|
||||||
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
|
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
|
||||||
@@ -111,7 +121,7 @@ class SchemaBuilder:
|
|||||||
try:
|
try:
|
||||||
serializer_class = model_instance.serializer
|
serializer_class = model_instance.serializer
|
||||||
except NotImplementedError as exc:
|
except NotImplementedError as exc:
|
||||||
raise ValueError(f"SerializerModel not implemented by {model}") from exc
|
raise NotImplementedError(model_instance) from exc
|
||||||
serializer = serializer_class(
|
serializer = serializer_class(
|
||||||
context={
|
context={
|
||||||
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
||||||
@@ -8,62 +8,45 @@ metadata:
|
|||||||
- Application (icon)
|
- Application (icon)
|
||||||
- Source (icon)
|
- Source (icon)
|
||||||
- Flow (background)
|
- Flow (background)
|
||||||
- Endpoint Enrollment token (key)
|
|
||||||
entries:
|
entries:
|
||||||
token:
|
- model: authentik_core.token
|
||||||
- model: authentik_core.token
|
identifiers:
|
||||||
identifiers:
|
identifier: "%(uid)s-token"
|
||||||
identifier: "%(uid)s-token"
|
attrs:
|
||||||
attrs:
|
key: "%(uid)s"
|
||||||
key: "%(uid)s"
|
user: "%(user)s"
|
||||||
user: "%(user)s"
|
intent: api
|
||||||
intent: api
|
- model: authentik_core.application
|
||||||
app:
|
identifiers:
|
||||||
- model: authentik_core.application
|
slug: "%(uid)s-app"
|
||||||
identifiers:
|
attrs:
|
||||||
slug: "%(uid)s-app"
|
name: "%(uid)s-app"
|
||||||
attrs:
|
icon: https://goauthentik.io/img/icon.png
|
||||||
name: "%(uid)s-app"
|
- model: authentik_sources_oauth.oauthsource
|
||||||
icon: https://goauthentik.io/img/icon.png
|
identifiers:
|
||||||
source:
|
slug: "%(uid)s-source"
|
||||||
- model: authentik_sources_oauth.oauthsource
|
attrs:
|
||||||
identifiers:
|
name: "%(uid)s-source"
|
||||||
slug: "%(uid)s-source"
|
provider_type: azuread
|
||||||
attrs:
|
consumer_key: "%(uid)s"
|
||||||
name: "%(uid)s-source"
|
consumer_secret: "%(uid)s"
|
||||||
provider_type: azuread
|
icon: https://goauthentik.io/img/icon.png
|
||||||
consumer_key: "%(uid)s"
|
- model: authentik_flows.flow
|
||||||
consumer_secret: "%(uid)s"
|
identifiers:
|
||||||
icon: https://goauthentik.io/img/icon.png
|
slug: "%(uid)s-flow"
|
||||||
flow:
|
attrs:
|
||||||
- model: authentik_flows.flow
|
name: "%(uid)s-flow"
|
||||||
identifiers:
|
title: "%(uid)s-flow"
|
||||||
slug: "%(uid)s-flow"
|
designation: authentication
|
||||||
attrs:
|
background: https://goauthentik.io/img/icon.png
|
||||||
name: "%(uid)s-flow"
|
- model: authentik_core.user
|
||||||
title: "%(uid)s-flow"
|
identifiers:
|
||||||
designation: authentication
|
username: "%(uid)s"
|
||||||
background: https://goauthentik.io/img/icon.png
|
attrs:
|
||||||
user:
|
name: "%(uid)s"
|
||||||
- model: authentik_core.user
|
password: "%(uid)s"
|
||||||
identifiers:
|
- model: authentik_core.user
|
||||||
username: "%(uid)s"
|
identifiers:
|
||||||
attrs:
|
username: "%(uid)s-no-password"
|
||||||
name: "%(uid)s"
|
attrs:
|
||||||
password: "%(uid)s"
|
name: "%(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
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ entries:
|
|||||||
name: foo
|
name: foo
|
||||||
title: foo
|
title: foo
|
||||||
permissions:
|
permissions:
|
||||||
- permission: authentik_flows.view_flow
|
- permission: view_flow
|
||||||
user: !KeyOf user
|
user: !KeyOf user
|
||||||
- permission: authentik_flows.view_flow
|
- permission: view_flow
|
||||||
role: !KeyOf role
|
role: !KeyOf role
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
from authentik.blueprints.v1.importer import Importer
|
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.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.generators import generate_id
|
||||||
from authentik.lib.tests.utils import load_fixture
|
from authentik.lib.tests.utils import load_fixture
|
||||||
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||||
@@ -28,20 +29,32 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
|||||||
self.assertIsNotNone(token)
|
self.assertIsNotNone(token)
|
||||||
self.assertEqual(token.key, self.uid)
|
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):
|
def test_user(self):
|
||||||
"""Test user"""
|
"""Test user"""
|
||||||
user = User.objects.filter(username=self.uid).first()
|
user: User = User.objects.filter(username=self.uid).first()
|
||||||
self.assertIsNotNone(user)
|
self.assertIsNotNone(user)
|
||||||
self.assertTrue(user.check_password(self.uid))
|
self.assertTrue(user.check_password(self.uid))
|
||||||
|
|
||||||
def test_user_null(self):
|
def test_user_null(self):
|
||||||
"""Test user"""
|
"""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.assertIsNotNone(user)
|
||||||
self.assertFalse(user.has_usable_password())
|
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)
|
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
|||||||
self.assertTrue(importer.apply())
|
self.assertTrue(importer.apply())
|
||||||
role = Role.objects.filter(name=uid).first()
|
role = Role.objects.filter(name=uid).first()
|
||||||
self.assertIsNotNone(role)
|
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):
|
def test_object_permission(self):
|
||||||
"""Test permissions"""
|
"""Test permissions"""
|
||||||
@@ -50,5 +53,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
|||||||
user = User.objects.filter(username=uid).first()
|
user = User.objects.filter(username=uid).first()
|
||||||
role = Role.objects.filter(name=uid).first()
|
role = Role.objects.filter(name=uid).first()
|
||||||
self.assertIsNotNone(flow)
|
self.assertIsNotNone(flow)
|
||||||
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
|
self.assertEqual(get_perms(user, flow), ["view_flow"])
|
||||||
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})
|
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
|||||||
instance.status,
|
instance.status,
|
||||||
BlueprintInstanceStatus.UNKNOWN,
|
BlueprintInstanceStatus.UNKNOWN,
|
||||||
)
|
)
|
||||||
apply_blueprint.send(instance.pk).get_result(block=True)
|
apply_blueprint(instance.pk)
|
||||||
instance.refresh_from_db()
|
instance.refresh_from_db()
|
||||||
self.assertEqual(instance.last_applied_hash, "")
|
self.assertEqual(instance.last_applied_hash, "")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from functools import reduce
|
|||||||
from json import JSONDecodeError, loads
|
from json import JSONDecodeError, loads
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from deepmerge import always_merger
|
from deepmerge import always_merger
|
||||||
@@ -43,6 +43,8 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
if _field.read_only:
|
if _field.read_only:
|
||||||
data.pop(field_name, None)
|
data.pop(field_name, None)
|
||||||
|
if _field.get_initial() == data.get(field_name, None):
|
||||||
|
data.pop(field_name, None)
|
||||||
if field_name.endswith("_set"):
|
if field_name.endswith("_set"):
|
||||||
data.pop(field_name, None)
|
data.pop(field_name, None)
|
||||||
return data
|
return data
|
||||||
@@ -68,17 +70,19 @@ class BlueprintEntryDesiredState(Enum):
|
|||||||
class BlueprintEntryPermission:
|
class BlueprintEntryPermission:
|
||||||
"""Describe object-level permissions"""
|
"""Describe object-level permissions"""
|
||||||
|
|
||||||
permission: str | YAMLTag
|
permission: Union[str, "YAMLTag"]
|
||||||
user: int | YAMLTag | None = field(default=None)
|
user: Union[int, "YAMLTag", None] = field(default=None)
|
||||||
role: str | YAMLTag | None = field(default=None)
|
role: Union[str, "YAMLTag", None] = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BlueprintEntry:
|
class BlueprintEntry:
|
||||||
"""Single entry of a blueprint"""
|
"""Single entry of a blueprint"""
|
||||||
|
|
||||||
model: str | YAMLTag
|
model: Union[str, "YAMLTag"]
|
||||||
state: BlueprintEntryDesiredState | YAMLTag = field(default=BlueprintEntryDesiredState.PRESENT)
|
state: Union[BlueprintEntryDesiredState, "YAMLTag"] = field(
|
||||||
|
default=BlueprintEntryDesiredState.PRESENT
|
||||||
|
)
|
||||||
conditions: list[Any] = field(default_factory=list)
|
conditions: list[Any] = field(default_factory=list)
|
||||||
identifiers: dict[str, Any] = field(default_factory=dict)
|
identifiers: dict[str, Any] = field(default_factory=dict)
|
||||||
attrs: dict[str, Any] | None = field(default_factory=dict)
|
attrs: dict[str, Any] | None = field(default_factory=dict)
|
||||||
@@ -92,7 +96,7 @@ class BlueprintEntry:
|
|||||||
self.__tag_contexts: list[YAMLTagContext] = []
|
self.__tag_contexts: list[YAMLTagContext] = []
|
||||||
|
|
||||||
@staticmethod
|
@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"""
|
"""Convert a SerializerModel instance to a blueprint Entry"""
|
||||||
identifiers = {
|
identifiers = {
|
||||||
"pk": model.pk,
|
"pk": model.pk,
|
||||||
@@ -110,8 +114,8 @@ class BlueprintEntry:
|
|||||||
def get_tag_context(
|
def get_tag_context(
|
||||||
self,
|
self,
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
context_tag_type: type[YAMLTagContext] | tuple[YAMLTagContext, ...] | None = None,
|
context_tag_type: type["YAMLTagContext"] | tuple["YAMLTagContext", ...] | None = None,
|
||||||
) -> YAMLTagContext:
|
) -> "YAMLTagContext":
|
||||||
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
||||||
if depth < 0:
|
if depth < 0:
|
||||||
raise ValueError("depth must be a positive number or zero")
|
raise ValueError("depth must be a positive number or zero")
|
||||||
@@ -126,7 +130,7 @@ class BlueprintEntry:
|
|||||||
except IndexError as exc:
|
except IndexError as exc:
|
||||||
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") from 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"""
|
"""Check if we have any special tags that need handling"""
|
||||||
val = copy(value)
|
val = copy(value)
|
||||||
|
|
||||||
@@ -148,23 +152,23 @@ class BlueprintEntry:
|
|||||||
|
|
||||||
return val
|
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"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
return self.tag_resolver(self.attrs, blueprint)
|
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"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
return self.tag_resolver(self.identifiers, blueprint)
|
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"""
|
"""Get the blueprint state, with yaml tags resolved if present"""
|
||||||
return BlueprintEntryDesiredState(self.tag_resolver(self.state, blueprint))
|
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"""
|
"""Get the blueprint model, with yaml tags resolved if present"""
|
||||||
return str(self.tag_resolver(self.model, blueprint))
|
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"""
|
"""Get permissions of this entry, with all yaml tags resolved"""
|
||||||
for perm in self.permissions:
|
for perm in self.permissions:
|
||||||
yield BlueprintEntryPermission(
|
yield BlueprintEntryPermission(
|
||||||
@@ -173,7 +177,7 @@ class BlueprintEntry:
|
|||||||
role=self.tag_resolver(perm.role, blueprint),
|
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)"""
|
"""Check all conditions of this entry match (evaluate to True)"""
|
||||||
return all(self.tag_resolver(self.conditions, blueprint))
|
return all(self.tag_resolver(self.conditions, blueprint))
|
||||||
|
|
||||||
@@ -228,7 +232,7 @@ class KeyOf(YAMLTag):
|
|||||||
|
|
||||||
id_from: str
|
id_from: str
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
|
|
||||||
@@ -254,7 +258,7 @@ class Env(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Any | None
|
default: Any | None
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
if isinstance(node, ScalarNode):
|
if isinstance(node, ScalarNode):
|
||||||
@@ -273,7 +277,7 @@ class File(YAMLTag):
|
|||||||
path: str
|
path: str
|
||||||
default: Any | None
|
default: Any | None
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
if isinstance(node, ScalarNode):
|
if isinstance(node, ScalarNode):
|
||||||
@@ -301,7 +305,7 @@ class Context(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Any | None
|
default: Any | None
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
if isinstance(node, ScalarNode):
|
if isinstance(node, ScalarNode):
|
||||||
@@ -324,7 +328,7 @@ class ParseJSON(YAMLTag):
|
|||||||
|
|
||||||
raw: str
|
raw: str
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.raw = node.value
|
self.raw = node.value
|
||||||
|
|
||||||
@@ -341,7 +345,7 @@ class Format(YAMLTag):
|
|||||||
format_string: str
|
format_string: str
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.format_string = loader.construct_object(node.value[0])
|
self.format_string = loader.construct_object(node.value[0])
|
||||||
self.args = []
|
self.args = []
|
||||||
@@ -368,7 +372,7 @@ class Find(YAMLTag):
|
|||||||
model_name: str | YAMLTag
|
model_name: str | YAMLTag
|
||||||
conditions: list[list]
|
conditions: list[list]
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model_name = loader.construct_object(node.value[0])
|
self.model_name = loader.construct_object(node.value[0])
|
||||||
self.conditions = []
|
self.conditions = []
|
||||||
@@ -440,7 +444,7 @@ class Condition(YAMLTag):
|
|||||||
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
"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__()
|
super().__init__()
|
||||||
self.mode = loader.construct_object(node.value[0])
|
self.mode = loader.construct_object(node.value[0])
|
||||||
self.args = []
|
self.args = []
|
||||||
@@ -474,7 +478,7 @@ class If(YAMLTag):
|
|||||||
when_true: Any
|
when_true: Any
|
||||||
when_false: Any
|
when_false: Any
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.condition = loader.construct_object(node.value[0])
|
self.condition = loader.construct_object(node.value[0])
|
||||||
if len(node.value) == 1:
|
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__()
|
super().__init__()
|
||||||
self.iterable = loader.construct_object(node.value[0])
|
self.iterable = loader.construct_object(node.value[0])
|
||||||
self.output_body = loader.construct_object(node.value[1])
|
self.output_body = loader.construct_object(node.value[1])
|
||||||
@@ -580,7 +584,7 @@ class EnumeratedItem(YAMLTag):
|
|||||||
|
|
||||||
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
||||||
|
|
||||||
def __init__(self, _loader: BlueprintLoader, node: ScalarNode) -> None:
|
def __init__(self, _loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.depth = int(node.value)
|
self.depth = int(node.value)
|
||||||
|
|
||||||
@@ -636,7 +640,7 @@ class AtIndex(YAMLTag):
|
|||||||
attribute: int | str | YAMLTag
|
attribute: int | str | YAMLTag
|
||||||
default: Any | UNSET
|
default: Any | UNSET
|
||||||
|
|
||||||
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.obj = loader.construct_object(node.value[0])
|
self.obj = loader.construct_object(node.value[0])
|
||||||
self.attribute = loader.construct_object(node.value[1])
|
self.attribute = loader.construct_object(node.value[1])
|
||||||
@@ -753,7 +757,7 @@ class EntryInvalidError(SentryIgnoredException):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_entry(
|
def from_entry(
|
||||||
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
|
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
|
||||||
) -> EntryInvalidError:
|
) -> "EntryInvalidError":
|
||||||
"""Create EntryInvalidError with the context of an entry"""
|
"""Create EntryInvalidError with the context of an entry"""
|
||||||
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
|
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
|
||||||
if isinstance(msg_or_exc, ValidationError):
|
if isinstance(msg_or_exc, ValidationError):
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from django.db.models import Model
|
|||||||
from django.db.models.query_utils import Q
|
from django.db.models.query_utils import Q
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
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.exceptions import ValidationError
|
||||||
from rest_framework.serializers import BaseSerializer, Serializer
|
from rest_framework.serializers import BaseSerializer, Serializer
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
@@ -40,16 +42,55 @@ from authentik.core.models import (
|
|||||||
User,
|
User,
|
||||||
UserSourceConnection,
|
UserSourceConnection,
|
||||||
)
|
)
|
||||||
from authentik.endpoints.models import Connector
|
from authentik.endpoints.connectors.agent.models import (
|
||||||
|
AgentDeviceConnection,
|
||||||
|
AppleNonce,
|
||||||
|
DeviceAuthenticationToken,
|
||||||
|
)
|
||||||
|
from authentik.endpoints.connectors.agent.models import (
|
||||||
|
DeviceToken as EndpointDeviceToken,
|
||||||
|
)
|
||||||
|
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
|
||||||
|
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.logs import LogEvent, capture_logs
|
||||||
from authentik.events.utils import cleanse_dict
|
from authentik.events.utils import cleanse_dict
|
||||||
from authentik.flows.models import Stage
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.lib.utils.reflection import get_apps
|
from authentik.lib.utils.reflection import get_apps
|
||||||
from authentik.outposts.models import OutpostServiceConnection
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
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.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
|
# Context set when the serializer is created in a blueprint context
|
||||||
# Update website/docs/customize/blueprints/v1/models.md when used
|
# Update website/docs/customize/blueprints/v1/models.md when used
|
||||||
@@ -69,7 +110,7 @@ def excluded_models() -> list[type[Model]]:
|
|||||||
DjangoGroup,
|
DjangoGroup,
|
||||||
ContentType,
|
ContentType,
|
||||||
Permission,
|
Permission,
|
||||||
RoleObjectPermission,
|
UserObjectPermission,
|
||||||
# Base classes
|
# Base classes
|
||||||
Provider,
|
Provider,
|
||||||
Source,
|
Source,
|
||||||
@@ -84,16 +125,49 @@ def excluded_models() -> list[type[Model]]:
|
|||||||
# Classes that have other dependencies
|
# Classes that have other dependencies
|
||||||
Session,
|
Session,
|
||||||
AuthenticatedSession,
|
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,
|
||||||
|
EndpointDeviceToken,
|
||||||
|
Device,
|
||||||
|
DeviceConnection,
|
||||||
|
DeviceAuthenticationToken,
|
||||||
|
AppleNonce,
|
||||||
|
AgentDeviceConnection,
|
||||||
|
DeviceFactSnapshot,
|
||||||
|
DeviceToken,
|
||||||
|
StreamEvent,
|
||||||
|
UserConsent,
|
||||||
|
SAMLSession,
|
||||||
|
Message,
|
||||||
|
GroupChannel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_model_allowed(model: type[Model]) -> bool:
|
def is_model_allowed(model: type[Model]) -> bool:
|
||||||
"""Check if model is allowed"""
|
"""Check if model is allowed"""
|
||||||
return (
|
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
|
||||||
model not in excluded_models()
|
|
||||||
and issubclass(model, SerializerModel | BaseMetaModel)
|
|
||||||
and not issubclass(model, InternallyManagedMixin)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DoRollback(SentryIgnoredException):
|
class DoRollback(SentryIgnoredException):
|
||||||
@@ -139,22 +213,13 @@ class Importer:
|
|||||||
|
|
||||||
def default_context(self):
|
def default_context(self):
|
||||||
"""Default context"""
|
"""Default context"""
|
||||||
context = {
|
return {
|
||||||
|
"goauthentik.io/enterprise/licensed": LicenseKey.get_total().status().is_valid,
|
||||||
"goauthentik.io/rbac/models": rbac_models(),
|
"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
|
@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"""
|
"""Parse YAML string and create blueprint importer from it"""
|
||||||
import_dict = load(yaml_input, BlueprintLoader)
|
import_dict = load(yaml_input, BlueprintLoader)
|
||||||
try:
|
try:
|
||||||
@@ -272,7 +337,7 @@ class Importer:
|
|||||||
and entry.state != BlueprintEntryDesiredState.MUST_CREATED
|
and entry.state != BlueprintEntryDesiredState.MUST_CREATED
|
||||||
):
|
):
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Initialize serializer with instance",
|
"Initialise serializer with instance",
|
||||||
model=model,
|
model=model,
|
||||||
instance=model_instance,
|
instance=model_instance,
|
||||||
pk=model_instance.pk,
|
pk=model_instance.pk,
|
||||||
@@ -290,7 +355,7 @@ class Importer:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Initialized new serializer instance",
|
"Initialised new serializer instance",
|
||||||
model=model,
|
model=model,
|
||||||
**cleanse_dict(updated_identifiers),
|
**cleanse_dict(updated_identifiers),
|
||||||
)
|
)
|
||||||
@@ -329,12 +394,10 @@ class Importer:
|
|||||||
"""Apply object-level permissions for an entry"""
|
"""Apply object-level permissions for an entry"""
|
||||||
for perm in entry.get_permissions(self._import):
|
for perm in entry.get_permissions(self._import):
|
||||||
if perm.user is not None:
|
if perm.user is not None:
|
||||||
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
|
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
|
||||||
perm.permission, instance
|
|
||||||
)
|
|
||||||
if perm.role is not None:
|
if perm.role is not None:
|
||||||
role = Role.objects.get(pk=perm.role)
|
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:
|
def apply(self) -> bool:
|
||||||
"""Apply (create/update) models yaml, in database transaction"""
|
"""Apply (create/update) models yaml, in database transaction"""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
# We cannot override `instance` as that will confuse rest_framework
|
# We cannot override `instance` as that will confuse rest_framework
|
||||||
# and make it attempt to update the instance
|
# and make it attempt to update the instance
|
||||||
blueprint_instance: BlueprintInstance
|
blueprint_instance: "BlueprintInstance"
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
from authentik.blueprints.models import BlueprintInstance
|
from authentik.blueprints.models import BlueprintInstance
|
||||||
@@ -37,21 +37,14 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
|||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
def create(self, validated_data: dict) -> MetaResult:
|
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:
|
if not self.blueprint_instance:
|
||||||
LOGGER.info("Blueprint does not exist, but not required")
|
LOGGER.info("Blueprint does not exist, but not required")
|
||||||
return MetaResult()
|
return MetaResult()
|
||||||
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
||||||
|
|
||||||
# Apply blueprint directly using Importer to avoid task context requirements
|
apply_blueprint(self.blueprint_instance.pk)
|
||||||
# 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()
|
|
||||||
return MetaResult()
|
return MetaResult()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.db import DatabaseError, InternalError, ProgrammingError
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
|
||||||
from dramatiq.actor import actor
|
from dramatiq.actor import actor
|
||||||
from dramatiq.middleware import Middleware
|
from dramatiq.middleware import Middleware
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
@@ -39,6 +40,7 @@ from authentik.events.utils import sanitize_dict
|
|||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.tasks.apps import PRIORITY_HIGH
|
from authentik.tasks.apps import PRIORITY_HIGH
|
||||||
from authentik.tasks.middleware import CurrentTask
|
from authentik.tasks.middleware import CurrentTask
|
||||||
|
from authentik.tasks.models import Task
|
||||||
from authentik.tasks.schedules.models import Schedule
|
from authentik.tasks.schedules.models import Schedule
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
@@ -189,7 +191,10 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
|||||||
|
|
||||||
@actor(description=_("Apply single blueprint."))
|
@actor(description=_("Apply single blueprint."))
|
||||||
def apply_blueprint(instance_pk: UUID):
|
def apply_blueprint(instance_pk: UUID):
|
||||||
self = CurrentTask.get_task()
|
try:
|
||||||
|
self = CurrentTask.get_task()
|
||||||
|
except CurrentTaskNotFound:
|
||||||
|
self = Task()
|
||||||
self.set_uid(str(instance_pk))
|
self.set_uid(str(instance_pk))
|
||||||
instance: BlueprintInstance | None = None
|
instance: BlueprintInstance | None = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ from django.db import models
|
|||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import (
|
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
|
||||||
CharField,
|
|
||||||
ChoiceField,
|
|
||||||
ListField,
|
|
||||||
SerializerMethodField,
|
|
||||||
)
|
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
@@ -21,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from authentik.brands.models import Brand
|
from authentik.brands.models import Brand
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
|
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||||
from authentik.rbac.filters import SecretKeyFilter
|
from authentik.rbac.filters import SecretKeyFilter
|
||||||
from authentik.tenants.api.settings import FlagJSONField
|
from authentik.tenants.api.settings import FlagJSONField
|
||||||
from authentik.tenants.flags import Flag
|
from authentik.tenants.flags import Flag
|
||||||
@@ -95,9 +90,7 @@ class CurrentBrandSerializer(PassiveSerializer):
|
|||||||
matched_domain = CharField(source="domain")
|
matched_domain = CharField(source="domain")
|
||||||
branding_title = CharField()
|
branding_title = CharField()
|
||||||
branding_logo = CharField(source="branding_logo_url")
|
branding_logo = CharField(source="branding_logo_url")
|
||||||
branding_logo_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
|
||||||
branding_favicon = CharField(source="branding_favicon_url")
|
branding_favicon = CharField(source="branding_favicon_url")
|
||||||
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
|
||||||
branding_custom_css = CharField()
|
branding_custom_css = CharField()
|
||||||
ui_footer_links = ListField(
|
ui_footer_links = ListField(
|
||||||
child=FooterLinkSerializer(),
|
child=FooterLinkSerializer(),
|
||||||
@@ -124,8 +117,10 @@ class CurrentBrandSerializer(PassiveSerializer):
|
|||||||
@extend_schema_field(field=FlagJSONField)
|
@extend_schema_field(field=FlagJSONField)
|
||||||
def get_flags(self, _):
|
def get_flags(self, _):
|
||||||
values = {}
|
values = {}
|
||||||
for flag in Flag.available(visibility="public"):
|
for flag in Flag.available():
|
||||||
values[flag().key] = flag.get()
|
_flag = flag()
|
||||||
|
if _flag.visibility == "public":
|
||||||
|
values[_flag.key] = _flag.get()
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -168,4 +163,4 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
|
|||||||
def current(self, request: Request) -> Response:
|
def current(self, request: Request) -> Response:
|
||||||
"""Get current brand"""
|
"""Get current brand"""
|
||||||
brand: Brand = request._request.brand
|
brand: Brand = request._request.brand
|
||||||
return Response(CurrentBrandSerializer(brand, context={"request": request}).data)
|
return Response(CurrentBrandSerializer(brand).data)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-27 16:22
|
|
||||||
|
|
||||||
import authentik.admin.files.fields
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_brands", "0010_brand_client_certificates_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="brand",
|
|
||||||
name="branding_default_flow_background",
|
|
||||||
field=authentik.admin.files.fields.FileField(
|
|
||||||
default="/static/dist/assets/images/flow_background.jpg"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="brand",
|
|
||||||
name="branding_favicon",
|
|
||||||
field=authentik.admin.files.fields.FileField(
|
|
||||||
default="/static/dist/assets/icons/icon.png"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="brand",
|
|
||||||
name="branding_logo",
|
|
||||||
field=authentik.admin.files.fields.FileField(
|
|
||||||
default="/static/dist/assets/icons/icon_left_brand.svg"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -8,11 +8,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.admin.files.fields import FileField
|
|
||||||
from authentik.admin.files.manager import get_file_manager
|
|
||||||
from authentik.admin.files.usage import FileUsage
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@@ -33,11 +31,11 @@ class Brand(SerializerModel):
|
|||||||
|
|
||||||
branding_title = models.TextField(default="authentik")
|
branding_title = models.TextField(default="authentik")
|
||||||
|
|
||||||
branding_logo = FileField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
||||||
branding_favicon = FileField(default="/static/dist/assets/icons/icon.png")
|
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
|
||||||
branding_custom_css = models.TextField(default="", blank=True)
|
branding_custom_css = models.TextField(default="", blank=True)
|
||||||
branding_default_flow_background = FileField(
|
branding_default_flow_background = models.TextField(
|
||||||
default="/static/dist/assets/images/flow_background.jpg",
|
default="/static/dist/assets/images/flow_background.jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
flow_authentication = models.ForeignKey(
|
flow_authentication = models.ForeignKey(
|
||||||
@@ -86,31 +84,25 @@ class Brand(SerializerModel):
|
|||||||
attributes = models.JSONField(default=dict, blank=True)
|
attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
def branding_logo_url(self) -> str:
|
def branding_logo_url(self) -> str:
|
||||||
"""Get branding_logo URL"""
|
"""Get branding_logo with the correct prefix"""
|
||||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
|
if self.branding_logo.startswith("/static"):
|
||||||
|
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
|
||||||
def branding_logo_themed_urls(self) -> dict[str, str] | None:
|
return self.branding_logo
|
||||||
"""Get themed URLs for branding_logo if it contains %(theme)s"""
|
|
||||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_logo)
|
|
||||||
|
|
||||||
def branding_favicon_url(self) -> str:
|
def branding_favicon_url(self) -> str:
|
||||||
"""Get branding_favicon URL"""
|
"""Get branding_favicon with the correct prefix"""
|
||||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
|
if self.branding_favicon.startswith("/static"):
|
||||||
|
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
|
||||||
def branding_favicon_themed_urls(self) -> dict[str, str] | None:
|
return self.branding_favicon
|
||||||
"""Get themed URLs for branding_favicon if it contains %(theme)s"""
|
|
||||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_favicon)
|
|
||||||
|
|
||||||
def branding_default_flow_background_url(self) -> str:
|
def branding_default_flow_background_url(self) -> str:
|
||||||
"""Get branding_default_flow_background URL"""
|
"""Get branding_default_flow_background with the correct prefix"""
|
||||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
|
if self.branding_default_flow_background.startswith("/static"):
|
||||||
|
return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background
|
||||||
def branding_default_flow_background_themed_urls(self) -> dict[str, str] | None:
|
return self.branding_default_flow_background
|
||||||
"""Get themed URLs for branding_default_flow_background if it contains %(theme)s"""
|
|
||||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_default_flow_background)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> Serializer:
|
||||||
from authentik.brands.api import BrandSerializer
|
from authentik.brands.api import BrandSerializer
|
||||||
|
|
||||||
return BrandSerializer
|
return BrandSerializer
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.urls import reverse
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.blueprints.tests import apply_blueprint
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
|
from authentik.brands.api import Themes
|
||||||
from authentik.brands.models import Brand
|
from authentik.brands.models import Brand
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
|
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
|
||||||
@@ -21,8 +22,10 @@ class TestBrands(APITestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.default_flags = {}
|
self.default_flags = {}
|
||||||
for flag in Flag.available(visibility="public"):
|
for flag in Flag.available():
|
||||||
self.default_flags[flag().key] = flag.get()
|
_flag = flag()
|
||||||
|
if _flag.visibility == "public":
|
||||||
|
self.default_flags[_flag.key] = _flag.get()
|
||||||
Brand.objects.all().delete()
|
Brand.objects.all().delete()
|
||||||
|
|
||||||
def test_current_brand(self):
|
def test_current_brand(self):
|
||||||
@@ -32,14 +35,12 @@ class TestBrands(APITestCase):
|
|||||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "authentik",
|
"branding_title": "authentik",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": brand.domain,
|
"matched_domain": brand.domain,
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -54,14 +55,12 @@ class TestBrands(APITestCase):
|
|||||||
).content.decode(),
|
).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "custom",
|
"branding_title": "custom",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "bar.baz",
|
"matched_domain": "bar.baz",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -73,14 +72,12 @@ class TestBrands(APITestCase):
|
|||||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "authentik",
|
"branding_title": "authentik",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "fallback",
|
"matched_domain": "fallback",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -97,14 +94,12 @@ class TestBrands(APITestCase):
|
|||||||
response,
|
response,
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "authentik",
|
"branding_title": "authentik",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "authentik-default",
|
"matched_domain": "authentik-default",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -122,14 +117,12 @@ class TestBrands(APITestCase):
|
|||||||
response,
|
response,
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "authentik",
|
"branding_title": "authentik",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "authentik-default",
|
"matched_domain": "authentik-default",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -140,14 +133,12 @@ class TestBrands(APITestCase):
|
|||||||
).content.decode(),
|
).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "custom",
|
"branding_title": "custom",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "bar.baz",
|
"matched_domain": "bar.baz",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -163,14 +154,12 @@ class TestBrands(APITestCase):
|
|||||||
).content.decode(),
|
).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "custom-strong",
|
"branding_title": "custom-strong",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "foo.bar.baz",
|
"matched_domain": "foo.bar.baz",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -186,14 +175,12 @@ class TestBrands(APITestCase):
|
|||||||
).content.decode(),
|
).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "custom-weak",
|
"branding_title": "custom-weak",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": "bar.baz",
|
"matched_domain": "bar.baz",
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
@@ -269,14 +256,12 @@ class TestBrands(APITestCase):
|
|||||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||||
{
|
{
|
||||||
"branding_logo": "https://goauthentik.io/img/icon.png",
|
"branding_logo": "https://goauthentik.io/img/icon.png",
|
||||||
"branding_logo_themed_urls": None,
|
|
||||||
"branding_favicon": "https://goauthentik.io/img/icon.png",
|
"branding_favicon": "https://goauthentik.io/img/icon.png",
|
||||||
"branding_favicon_themed_urls": None,
|
|
||||||
"branding_title": "authentik",
|
"branding_title": "authentik",
|
||||||
"branding_custom_css": "",
|
"branding_custom_css": "",
|
||||||
"matched_domain": brand.domain,
|
"matched_domain": brand.domain,
|
||||||
"ui_footer_links": [],
|
"ui_footer_links": [],
|
||||||
"ui_theme": "automatic",
|
"ui_theme": Themes.AUTOMATIC,
|
||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
"flags": self.default_flags,
|
"flags": self.default_flags,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db.models import Case, F, IntegerField, Q, Value, When
|
from django.db.models import Case, F, IntegerField, Q, Value, When
|
||||||
from django.db.models.functions import Concat, Length
|
from django.db.models.functions import Length
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from django.utils.html import _json_script_escapes
|
from django.utils.html import _json_script_escapes
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@@ -26,8 +26,7 @@ def get_brand_for_request(request: HttpRequest) -> Brand:
|
|||||||
domain_length=Length("domain"),
|
domain_length=Length("domain"),
|
||||||
match_priority=Case(
|
match_priority=Case(
|
||||||
When(
|
When(
|
||||||
condition=Q(host_domain__iexact=F("domain"))
|
condition=Q(host_domain__iendswith=F("domain")),
|
||||||
| Q(host_domain__iendswith=Concat(Value("."), F("domain"))),
|
|
||||||
then=F("domain_length"),
|
then=F("domain_length"),
|
||||||
),
|
),
|
||||||
default=Value(-1),
|
default=Value(-1),
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ from collections.abc import Iterator
|
|||||||
from copy import copy
|
from copy import copy
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Case, QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models.expressions import When
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||||
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
@@ -23,13 +23,19 @@ from authentik.api.pagination import Pagination
|
|||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.api.providers import ProviderSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.users import UserSerializer
|
from authentik.core.api.utils import ModelSerializer
|
||||||
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
|
|
||||||
from authentik.core.models import Application, User
|
from authentik.core.models import Application, User
|
||||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||||
|
from authentik.lib.utils.file import (
|
||||||
|
FilePathSerializer,
|
||||||
|
FileUploadSerializer,
|
||||||
|
set_file,
|
||||||
|
set_file_url,
|
||||||
|
)
|
||||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.policies.types import CACHE_PREFIX, PolicyResult
|
from authentik.policies.types import CACHE_PREFIX, PolicyResult
|
||||||
|
from authentik.rbac.decorators import permission_required
|
||||||
from authentik.rbac.filters import ObjectFilter
|
from authentik.rbac.filters import ObjectFilter
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@@ -52,29 +58,14 @@ class ApplicationSerializer(ModelSerializer):
|
|||||||
source="backchannel_providers", required=False, read_only=True, many=True
|
source="backchannel_providers", required=False, read_only=True, many=True
|
||||||
)
|
)
|
||||||
|
|
||||||
meta_icon_url = ReadOnlyField(source="get_meta_icon")
|
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||||
meta_icon_themed_urls = ThemedUrlsSerializer(
|
|
||||||
source="get_meta_icon_themed_urls", read_only=True, allow_null=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_launch_url(self, app: Application) -> str | None:
|
def get_launch_url(self, app: Application) -> str | None:
|
||||||
"""Allow formatting of launch URL"""
|
"""Allow formatting of launch URL"""
|
||||||
user = None
|
user = None
|
||||||
user_data = None
|
|
||||||
|
|
||||||
if "request" in self.context:
|
if "request" in self.context:
|
||||||
user = self.context["request"].user
|
user = self.context["request"].user
|
||||||
|
return app.get_launch_url(user)
|
||||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
|
||||||
# for multiple applications. UserSerializer accesses user.groups which
|
|
||||||
# would otherwise trigger a query for each application.
|
|
||||||
if user is not None:
|
|
||||||
if "_cached_user_data" not in self.context:
|
|
||||||
# Prefetch groups to avoid N+1
|
|
||||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
|
||||||
user_data = self.context["_cached_user_data"]
|
|
||||||
|
|
||||||
return app.get_launch_url(user, user_data=user_data)
|
|
||||||
|
|
||||||
def validate_slug(self, slug: str) -> str:
|
def validate_slug(self, slug: str) -> str:
|
||||||
if slug in Application.reserved_slugs:
|
if slug in Application.reserved_slugs:
|
||||||
@@ -104,14 +95,13 @@ class ApplicationSerializer(ModelSerializer):
|
|||||||
"open_in_new_tab",
|
"open_in_new_tab",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon",
|
"meta_icon",
|
||||||
"meta_icon_url",
|
|
||||||
"meta_icon_themed_urls",
|
|
||||||
"meta_description",
|
"meta_description",
|
||||||
"meta_publisher",
|
"meta_publisher",
|
||||||
"policy_engine_mode",
|
"policy_engine_mode",
|
||||||
"group",
|
"group",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
|
"meta_icon": {"read_only": True},
|
||||||
"backchannel_providers": {"required": False},
|
"backchannel_providers": {"required": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,37 +144,22 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def _get_allowed_applications(
|
def _get_allowed_applications(
|
||||||
self, paginated_apps: Iterator[Application], user: User | None = None
|
self, pagined_apps: Iterator[Application], user: User | None = None
|
||||||
) -> list[Application]:
|
) -> list[Application]:
|
||||||
applications = []
|
applications = []
|
||||||
request = self.request._request
|
request = self.request._request
|
||||||
if user:
|
if user:
|
||||||
request = copy(request)
|
request = copy(request)
|
||||||
request.user = user
|
request.user = user
|
||||||
for application in paginated_apps:
|
for application in pagined_apps:
|
||||||
engine = PolicyEngine(application, request.user, request)
|
engine = PolicyEngine(application, request.user, request)
|
||||||
engine.build()
|
engine.build()
|
||||||
if engine.passing:
|
if engine.passing:
|
||||||
applications.append(application)
|
applications.append(application)
|
||||||
return applications
|
return applications
|
||||||
|
|
||||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
|
||||||
"""
|
|
||||||
Re-fetch with proper prefetching for serialization
|
|
||||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
|
||||||
during serialization when get_provider() is called
|
|
||||||
"""
|
|
||||||
if not applications:
|
|
||||||
return self.get_queryset().none()
|
|
||||||
pks = [app.pk for app in applications]
|
|
||||||
return (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(pk__in=pks)
|
|
||||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _filter_applications_with_launch_url(
|
def _filter_applications_with_launch_url(
|
||||||
self, paginated_apps: QuerySet[Application]
|
self, paginated_apps: Iterator[Application]
|
||||||
) -> list[Application]:
|
) -> list[Application]:
|
||||||
applications = []
|
applications = []
|
||||||
for app in paginated_apps:
|
for app in paginated_apps:
|
||||||
@@ -287,8 +262,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValidationError from exc
|
raise ValidationError from exc
|
||||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||||
allowed_applications = self._expand_applications(allowed_applications)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(allowed_applications, many=True)
|
serializer = self.get_serializer(allowed_applications, many=True)
|
||||||
return self.get_paginated_response(serializer.data)
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
@@ -307,10 +280,50 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
allowed_applications,
|
allowed_applications,
|
||||||
timeout=86400,
|
timeout=86400,
|
||||||
)
|
)
|
||||||
allowed_applications = self._expand_applications(allowed_applications)
|
|
||||||
|
|
||||||
if only_with_launch_url == "true":
|
if only_with_launch_url == "true":
|
||||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||||
|
|
||||||
serializer = self.get_serializer(allowed_applications, many=True)
|
serializer = self.get_serializer(allowed_applications, many=True)
|
||||||
return self.get_paginated_response(serializer.data)
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
@permission_required("authentik_core.change_application")
|
||||||
|
@extend_schema(
|
||||||
|
request={
|
||||||
|
"multipart/form-data": FileUploadSerializer,
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Success"),
|
||||||
|
400: OpenApiResponse(description="Bad request"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
pagination_class=None,
|
||||||
|
filter_backends=[],
|
||||||
|
methods=["POST"],
|
||||||
|
parser_classes=(MultiPartParser,),
|
||||||
|
)
|
||||||
|
def set_icon(self, request: Request, slug: str):
|
||||||
|
"""Set application icon"""
|
||||||
|
app: Application = self.get_object()
|
||||||
|
return set_file(request, app, "meta_icon")
|
||||||
|
|
||||||
|
@permission_required("authentik_core.change_application")
|
||||||
|
@extend_schema(
|
||||||
|
request=FilePathSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Success"),
|
||||||
|
400: OpenApiResponse(description="Bad request"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
pagination_class=None,
|
||||||
|
filter_backends=[],
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
|
"""Set application icon (as URL)"""
|
||||||
|
app: Application = self.get_object()
|
||||||
|
return set_file_url(request, app, "meta_icon")
|
||||||
|
|||||||
@@ -2,31 +2,18 @@
|
|||||||
|
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from drf_spectacular.utils import (
|
from rest_framework import mixins
|
||||||
extend_schema,
|
|
||||||
inline_serializer,
|
|
||||||
)
|
|
||||||
from rest_framework import mixins, serializers
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
|
||||||
from rest_framework.serializers import (
|
|
||||||
CharField,
|
|
||||||
DateTimeField,
|
|
||||||
IPAddressField,
|
|
||||||
ListField,
|
|
||||||
)
|
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
from ua_parser import user_agent_parser
|
from ua_parser import user_agent_parser
|
||||||
|
|
||||||
from authentik.api.validation import validate
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
from authentik.core.api.utils import ModelSerializer
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
|
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
|
||||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
|
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
|
||||||
from authentik.rbac.decorators import permission_required
|
|
||||||
|
|
||||||
|
|
||||||
class UserAgentDeviceDict(TypedDict):
|
class UserAgentDeviceDict(TypedDict):
|
||||||
@@ -65,14 +52,6 @@ class UserAgentDict(TypedDict):
|
|||||||
string: str
|
string: str
|
||||||
|
|
||||||
|
|
||||||
class BulkDeleteSessionSerializer(PassiveSerializer):
|
|
||||||
"""Serializer for bulk deleting authenticated sessions by user"""
|
|
||||||
|
|
||||||
user_pks = ListField(
|
|
||||||
child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedSessionSerializer(ModelSerializer):
|
class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
"""AuthenticatedSession Serializer"""
|
"""AuthenticatedSession Serializer"""
|
||||||
|
|
||||||
@@ -136,22 +115,3 @@ class AuthenticatedSessionViewSet(
|
|||||||
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
|
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
|
||||||
ordering = ["user__username"]
|
ordering = ["user__username"]
|
||||||
owner_field = "user"
|
owner_field = "user"
|
||||||
|
|
||||||
@permission_required("authentik_core.delete_authenticatedsession")
|
|
||||||
@extend_schema(
|
|
||||||
parameters=[BulkDeleteSessionSerializer],
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
"BulkDeleteSessionResponse",
|
|
||||||
{"deleted": serializers.IntegerField()},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate(BulkDeleteSessionSerializer, location="query")
|
|
||||||
@action(detail=False, methods=["DELETE"], pagination_class=None, filter_backends=[])
|
|
||||||
def bulk_delete(self, request: Request, *, query: BulkDeleteSessionSerializer) -> Response:
|
|
||||||
"""Bulk revoke all sessions for multiple users"""
|
|
||||||
user_pks = query.validated_data.get("user_pks", [])
|
|
||||||
deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_pks).delete()
|
|
||||||
|
|
||||||
return Response({"deleted": deleted_count}, status=200)
|
|
||||||
|
|||||||
@@ -16,15 +16,11 @@ from rest_framework.viewsets import ViewSet
|
|||||||
from authentik.api.validation import validate
|
from authentik.api.validation import validate
|
||||||
from authentik.core.api.users import ParamUserSerializer
|
from authentik.core.api.users import ParamUserSerializer
|
||||||
from authentik.core.api.utils import MetaNameSerializer
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
|
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
||||||
from authentik.stages.authenticator import device_classes, devices_for_user
|
from authentik.stages.authenticator import device_classes, devices_for_user
|
||||||
from authentik.stages.authenticator.models import Device
|
from authentik.stages.authenticator.models import Device
|
||||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||||
|
|
||||||
try:
|
|
||||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
EndpointDevice = None
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceSerializer(MetaNameSerializer):
|
class DeviceSerializer(MetaNameSerializer):
|
||||||
"""Serializer for authenticator devices"""
|
"""Serializer for authenticator devices"""
|
||||||
@@ -47,7 +43,7 @@ class DeviceSerializer(MetaNameSerializer):
|
|||||||
"""Get extra description"""
|
"""Get extra description"""
|
||||||
if isinstance(instance, WebAuthnDevice):
|
if isinstance(instance, WebAuthnDevice):
|
||||||
return instance.device_type.description if instance.device_type else None
|
return instance.device_type.description if instance.device_type else None
|
||||||
if EndpointDevice and isinstance(instance, EndpointDevice):
|
if isinstance(instance, EndpointDevice):
|
||||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -55,7 +51,7 @@ class DeviceSerializer(MetaNameSerializer):
|
|||||||
"""Get external Device ID"""
|
"""Get external Device ID"""
|
||||||
if isinstance(instance, WebAuthnDevice):
|
if isinstance(instance, WebAuthnDevice):
|
||||||
return instance.device_type.aaguid if instance.device_type else None
|
return instance.device_type.aaguid if instance.device_type else None
|
||||||
if EndpointDevice and isinstance(instance, EndpointDevice):
|
if isinstance(instance, EndpointDevice):
|
||||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -76,13 +72,13 @@ class AdminDeviceViewSet(ViewSet):
|
|||||||
"""Viewset for authenticator devices"""
|
"""Viewset for authenticator devices"""
|
||||||
|
|
||||||
serializer_class = DeviceSerializer
|
serializer_class = DeviceSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = []
|
||||||
|
|
||||||
def get_devices(self, **kwargs):
|
def get_devices(self, **kwargs):
|
||||||
"""Get all devices in all child classes"""
|
"""Get all devices in all child classes"""
|
||||||
for model in device_classes():
|
for model in device_classes():
|
||||||
device_set = get_objects_for_user(
|
device_set = get_objects_for_user(
|
||||||
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}"
|
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
|
||||||
).filter(**kwargs)
|
).filter(**kwargs)
|
||||||
yield from device_set
|
yield from device_set
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ from guardian.shortcuts import get_objects_for_user
|
|||||||
from rest_framework.authentication import SessionAuthentication
|
from rest_framework.authentication import SessionAuthentication
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.relations import PrimaryKeyRelatedField
|
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ListSerializer, ValidationError
|
from rest_framework.serializers import ListSerializer, ValidationError
|
||||||
|
from rest_framework.validators import UniqueValidator
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.api.authentication import TokenAuthentication
|
from authentik.api.authentication import TokenAuthentication
|
||||||
@@ -33,16 +32,6 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
|||||||
from authentik.rbac.api.roles import RoleSerializer
|
from authentik.rbac.api.roles import RoleSerializer
|
||||||
from authentik.rbac.decorators import permission_required
|
from authentik.rbac.decorators import permission_required
|
||||||
|
|
||||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
|
||||||
"pk",
|
|
||||||
"username",
|
|
||||||
"name",
|
|
||||||
"is_active",
|
|
||||||
"last_login",
|
|
||||||
"email",
|
|
||||||
"attributes",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PartialUserSerializer(ModelSerializer):
|
class PartialUserSerializer(ModelSerializer):
|
||||||
"""Partial User Serializer, does not include child relations."""
|
"""Partial User Serializer, does not include child relations."""
|
||||||
@@ -52,11 +41,20 @@ class PartialUserSerializer(ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
fields = [
|
||||||
|
"pk",
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"is_active",
|
||||||
|
"last_login",
|
||||||
|
"email",
|
||||||
|
"attributes",
|
||||||
|
"uid",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RelatedGroupSerializer(ModelSerializer):
|
class GroupChildSerializer(ModelSerializer):
|
||||||
"""Stripped down group serializer to show relevant children/parents for groups"""
|
"""Stripped down group serializer to show relevant children for groups"""
|
||||||
|
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONDictField(required=False)
|
||||||
|
|
||||||
@@ -75,17 +73,15 @@ class GroupSerializer(ModelSerializer):
|
|||||||
"""Group Serializer"""
|
"""Group Serializer"""
|
||||||
|
|
||||||
attributes = JSONDictField(required=False)
|
attributes = JSONDictField(required=False)
|
||||||
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
|
||||||
parents_obj = SerializerMethodField(allow_null=True)
|
|
||||||
children_obj = SerializerMethodField(allow_null=True)
|
|
||||||
users_obj = SerializerMethodField(allow_null=True)
|
users_obj = SerializerMethodField(allow_null=True)
|
||||||
|
children_obj = SerializerMethodField(allow_null=True)
|
||||||
roles_obj = ListSerializer(
|
roles_obj = ListSerializer(
|
||||||
child=RoleSerializer(),
|
child=RoleSerializer(),
|
||||||
read_only=True,
|
read_only=True,
|
||||||
source="roles",
|
source="roles",
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
inherited_roles_obj = SerializerMethodField(allow_null=True)
|
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||||
num_pk = IntegerField(read_only=True)
|
num_pk = IntegerField(read_only=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -102,46 +98,25 @@ class GroupSerializer(ModelSerializer):
|
|||||||
return True
|
return True
|
||||||
return str(request.query_params.get("include_children", "false")).lower() == "true"
|
return str(request.query_params.get("include_children", "false")).lower() == "true"
|
||||||
|
|
||||||
@property
|
|
||||||
def _should_include_parents(self) -> bool:
|
|
||||||
request: Request = self.context.get("request", None)
|
|
||||||
if not request:
|
|
||||||
return True
|
|
||||||
return str(request.query_params.get("include_parents", "false")).lower() == "true"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _should_include_inherited_roles(self) -> bool:
|
|
||||||
request: Request = self.context.get("request", None)
|
|
||||||
if not request:
|
|
||||||
return True
|
|
||||||
return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true"
|
|
||||||
|
|
||||||
@extend_schema_field(PartialUserSerializer(many=True))
|
@extend_schema_field(PartialUserSerializer(many=True))
|
||||||
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
||||||
if not self._should_include_users:
|
if not self._should_include_users:
|
||||||
return None
|
return None
|
||||||
return PartialUserSerializer(instance.users, many=True).data
|
return PartialUserSerializer(instance.users, many=True).data
|
||||||
|
|
||||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
@extend_schema_field(GroupChildSerializer(many=True))
|
||||||
def get_children_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
|
||||||
if not self._should_include_children:
|
if not self._should_include_children:
|
||||||
return None
|
return None
|
||||||
return RelatedGroupSerializer(instance.children, many=True).data
|
return GroupChildSerializer(instance.children, many=True).data
|
||||||
|
|
||||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
def validate_parent(self, parent: Group | None):
|
||||||
def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
"""Validate group parent (if set), ensuring the parent isn't itself"""
|
||||||
if not self._should_include_parents:
|
if not self.instance or not parent:
|
||||||
return None
|
return parent
|
||||||
return RelatedGroupSerializer(instance.parents, many=True).data
|
if str(parent.group_uuid) == str(self.instance.group_uuid):
|
||||||
|
raise ValidationError(_("Cannot set group as parent of itself."))
|
||||||
@extend_schema_field(RoleSerializer(many=True))
|
return parent
|
||||||
def get_inherited_roles_obj(self, instance: Group) -> list | None:
|
|
||||||
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
|
|
||||||
if not self._should_include_inherited_roles:
|
|
||||||
return None
|
|
||||||
direct_role_pks = instance.roles.values_list("pk", flat=True)
|
|
||||||
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
|
|
||||||
return RoleSerializer(inherited_roles, many=True).data
|
|
||||||
|
|
||||||
def validate_is_superuser(self, superuser: bool):
|
def validate_is_superuser(self, superuser: bool):
|
||||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||||
@@ -177,14 +152,13 @@ class GroupSerializer(ModelSerializer):
|
|||||||
"num_pk",
|
"num_pk",
|
||||||
"name",
|
"name",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
"parents",
|
"parent",
|
||||||
"parents_obj",
|
"parent_name",
|
||||||
"users",
|
"users",
|
||||||
"users_obj",
|
"users_obj",
|
||||||
"attributes",
|
"attributes",
|
||||||
"roles",
|
"roles",
|
||||||
"roles_obj",
|
"roles_obj",
|
||||||
"inherited_roles_obj",
|
|
||||||
"children",
|
"children",
|
||||||
"children_obj",
|
"children_obj",
|
||||||
]
|
]
|
||||||
@@ -196,10 +170,9 @@ class GroupSerializer(ModelSerializer):
|
|||||||
"required": False,
|
"required": False,
|
||||||
"default": list,
|
"default": list,
|
||||||
},
|
},
|
||||||
"parents": {
|
# TODO: This field isn't unique on the database which is hard to backport
|
||||||
"required": False,
|
# hence we just validate the uniqueness here
|
||||||
"default": list,
|
"name": {"validators": [UniqueValidator(Group.objects.all())]},
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -274,21 +247,14 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
return [
|
return [
|
||||||
StrField(Group, "name"),
|
StrField(Group, "name"),
|
||||||
BoolField(Group, "is_superuser", nullable=True),
|
BoolField(Group, "is_superuser", nullable=True),
|
||||||
JSONSearchField(Group, "attributes"),
|
JSONSearchField(Group, "attributes", suggest_nested=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
base_qs = Group.objects.all().prefetch_related("roles")
|
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
|
||||||
|
|
||||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
base_qs = base_qs.prefetch_related("users")
|
||||||
# time
|
|
||||||
base_qs = base_qs.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"users",
|
|
||||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
base_qs = base_qs.prefetch_related(
|
base_qs = base_qs.prefetch_related(
|
||||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||||
@@ -297,17 +263,12 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
if self.serializer_class(context={"request": self.request})._should_include_children:
|
if self.serializer_class(context={"request": self.request})._should_include_children:
|
||||||
base_qs = base_qs.prefetch_related("children")
|
base_qs = base_qs.prefetch_related("children")
|
||||||
|
|
||||||
if self.serializer_class(context={"request": self.request})._should_include_parents:
|
|
||||||
base_qs = base_qs.prefetch_related("parents")
|
|
||||||
|
|
||||||
return base_qs
|
return base_qs
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter("include_users", bool, default=True),
|
OpenApiParameter("include_users", bool, default=True),
|
||||||
OpenApiParameter("include_children", bool, default=False),
|
OpenApiParameter("include_children", bool, default=False),
|
||||||
OpenApiParameter("include_parents", bool, default=False),
|
|
||||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
@@ -317,8 +278,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter("include_users", bool, default=True),
|
OpenApiParameter("include_users", bool, default=True),
|
||||||
OpenApiParameter("include_children", bool, default=False),
|
OpenApiParameter("include_children", bool, default=False),
|
||||||
OpenApiParameter("include_parents", bool, default=False),
|
|
||||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
@@ -337,7 +296,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
pagination_class=None,
|
pagination_class=None,
|
||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
permission_classes=[IsAuthenticated],
|
permission_classes=[],
|
||||||
)
|
)
|
||||||
@validate(UserAccountSerializer)
|
@validate(UserAccountSerializer)
|
||||||
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||||
@@ -368,7 +327,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
pagination_class=None,
|
pagination_class=None,
|
||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
permission_classes=[IsAuthenticated],
|
permission_classes=[],
|
||||||
)
|
)
|
||||||
@validate(UserAccountSerializer)
|
@validate(UserAccountSerializer)
|
||||||
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.lib.models import DeprecatedMixin
|
from authentik.enterprise.apps import EnterpriseConfig
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ class TypeCreateSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
icon_url = CharField(required=False)
|
icon_url = CharField(required=False)
|
||||||
requires_enterprise = BooleanField(default=False)
|
requires_enterprise = BooleanField(default=False)
|
||||||
deprecated = BooleanField(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CreatableType:
|
class CreatableType:
|
||||||
@@ -60,25 +59,18 @@ class TypesMixin:
|
|||||||
continue
|
continue
|
||||||
instance = subclass()
|
instance = subclass()
|
||||||
try:
|
try:
|
||||||
type_signature = {
|
data.append(
|
||||||
"name": subclass._meta.verbose_name,
|
{
|
||||||
"description": subclass.__doc__,
|
"name": subclass._meta.verbose_name,
|
||||||
"component": instance.component,
|
"description": subclass.__doc__,
|
||||||
"model_name": subclass._meta.model_name,
|
"component": instance.component,
|
||||||
"icon_url": getattr(instance, "icon_url", None),
|
"model_name": subclass._meta.model_name,
|
||||||
"requires_enterprise": False,
|
"icon_url": getattr(instance, "icon_url", None),
|
||||||
"deprecated": isinstance(instance, DeprecatedMixin),
|
"requires_enterprise": isinstance(
|
||||||
}
|
subclass._meta.app_config, EnterpriseConfig
|
||||||
try:
|
),
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
}
|
||||||
|
)
|
||||||
type_signature["requires_enterprise"] = isinstance(
|
|
||||||
subclass._meta.app_config, EnterpriseConfig
|
|
||||||
)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
data.append(type_signature)
|
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
continue
|
continue
|
||||||
if additional:
|
if additional:
|
||||||
|
|||||||
@@ -18,14 +18,10 @@ from authentik.core.models import Provider
|
|||||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Provider Serializer"""
|
"""Provider Serializer"""
|
||||||
|
|
||||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
assigned_application_name = ReadOnlyField(source="application.name")
|
||||||
assigned_backchannel_application_slug = ReadOnlyField(
|
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
||||||
source="backchannel_application.slug", allow_null=True
|
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
||||||
)
|
|
||||||
assigned_backchannel_application_name = ReadOnlyField(
|
|
||||||
source="backchannel_application.name", allow_null=True
|
|
||||||
)
|
|
||||||
|
|
||||||
component = SerializerMethodField()
|
component = SerializerMethodField()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user