mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 15:12:13 +02:00
Compare commits
12 Commits
version-20
...
enforce-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc794d927e | ||
|
|
01b91e9dd1 | ||
|
|
6efb3f5fb0 | ||
|
|
4b24a7ddc1 | ||
|
|
b6b423bee9 | ||
|
|
860a43e56a | ||
|
|
c1cbd0623c | ||
|
|
0893031cab | ||
|
|
89868276da | ||
|
|
dd40f60c88 | ||
|
|
73be48c179 | ||
|
|
fc27dfeeb7 |
23
.github/actions/cherry-pick/action.yml
vendored
23
.github/actions/cherry-pick/action.yml
vendored
@@ -115,13 +115,20 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
|
||||
REASON: ${{ steps.should_run.outputs.reason }}
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
|
||||
|
||||
# Get PR details
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
|
||||
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine which labels to process
|
||||
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
|
||||
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
|
||||
# Only process the specific label that was just added
|
||||
if [ "${{ github.event_name }}" = "issues" ]; then
|
||||
LABEL_NAME="${{ github.event.label.name }}"
|
||||
@@ -145,13 +152,13 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
LABELS: '${{ steps.pr_details.outputs.labels }}'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER='${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA='${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE='${{ steps.pr_details.outputs.pr_title }}'
|
||||
PR_AUTHOR='${{ steps.pr_details.outputs.pr_author }}'
|
||||
LABELS='${{ steps.pr_details.outputs.labels }}'
|
||||
|
||||
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
|
||||
echo "Found backport labels: $LABELS"
|
||||
|
||||
25
.github/actions/setup/action.yml
vendored
25
.github/actions/setup/action.yml
vendored
@@ -8,51 +8,44 @@ inputs:
|
||||
postgresql_version:
|
||||
description: "Optional postgresql image tag"
|
||||
default: "16"
|
||||
working-directory:
|
||||
description: |
|
||||
Optional working directory if this repo isn't in the root of the actions workspace.
|
||||
When set, needs to contain a trailing slash
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install apt deps & cleanup
|
||||
- name: Install apt deps
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get remove --purge man-db
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
|
||||
with:
|
||||
python-version-file: "${{ inputs.working-directory }}pyproject.toml"
|
||||
python-version-file: "pyproject.toml"
|
||||
- name: Install Python deps
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup node
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.working-directory }}web/package.json
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: ${{ inputs.working-directory }}web/package-lock.json
|
||||
cache-dependency-path: web/package-lock.json
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Setup go
|
||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||
with:
|
||||
go-version-file: "${{ inputs.working-directory }}go.mod"
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup docker cache
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||
@@ -61,15 +54,13 @@ runs:
|
||||
- name: Setup dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
cd web && npm ci
|
||||
cd web && npm i
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: uv run python {0}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
from authentik.lib.generators import generate_id
|
||||
from yaml import safe_dump
|
||||
|
||||
2
.github/actions/setup/docker-compose.yml
vendored
2
.github/actions/setup/docker-compose.yml
vendored
@@ -2,7 +2,7 @@ services:
|
||||
postgresql:
|
||||
image: docker.io/library/postgres:${PSQL_TAG:-16}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
- db-data:/var/lib/postgresql/data
|
||||
command: "-c log_statement=all"
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
|
||||
4
.github/actions/test-results/action.yml
vendored
4
.github/actions/test-results/action.yml
vendored
@@ -8,7 +8,7 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
@@ -20,7 +20,7 @@ runs:
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ $RUNNER_DEBUG == '1' ]]; then
|
||||
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
|
||||
docker stop setup-postgresql-1
|
||||
echo "::group::PostgreSQL Logs"
|
||||
docker logs setup-postgresql-1
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -2,10 +2,6 @@
|
||||
👋 Hi there! Welcome.
|
||||
|
||||
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
|
||||
|
||||
⚠️ IMPORTANT: Make sure you are opening this PR from a FEATURE BRANCH, not from your main branch!
|
||||
If you opened this PR from your main branch, please close it and create a new feature branch instead.
|
||||
For more information, see: https://docs.goauthentik.io/developer-docs/contributing/#always-use-feature-branches
|
||||
-->
|
||||
|
||||
## Details
|
||||
|
||||
@@ -73,18 +73,15 @@ jobs:
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: generate ts client
|
||||
if: ${{ !inputs.release }}
|
||||
run: make gen-client-ts
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
id: push
|
||||
|
||||
6
.github/workflows/api-ts-publish.yml
vendored
6
.github/workflows/api-ts-publish.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
2
.github/workflows/ci-api-docs.yml
vendored
2
.github/workflows/ci-api-docs.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
|
||||
10
.github/workflows/ci-main.yml
vendored
10
.github/workflows/ci-main.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
# Current version family based on
|
||||
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
|
||||
if [[ -n $current_version_family ]]; then
|
||||
prev_stable="version/${current_version_family}"
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout ${prev_stable}
|
||||
@@ -95,10 +95,7 @@ jobs:
|
||||
with:
|
||||
postgresql_version: ${{ matrix.psql }}
|
||||
- name: run migrations to stable
|
||||
run: |
|
||||
docker ps
|
||||
docker logs setup-postgresql-1
|
||||
uv run python -m lifecycle.migrate
|
||||
run: uv run python -m lifecycle.migrate
|
||||
- name: checkout current code
|
||||
run: |
|
||||
set -x
|
||||
@@ -204,7 +201,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
@@ -229,6 +226,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- test-migrations
|
||||
- test-migrations-from-stable
|
||||
- test-unittest
|
||||
- test-integration
|
||||
- test-e2e
|
||||
|
||||
6
.github/workflows/gen-image-compress.yml
vendored
6
.github/workflows/gen-image-compress.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||
id: cpr
|
||||
with:
|
||||
|
||||
@@ -16,17 +16,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: uv run ak update_webauthn_mds
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/gh-cherry-pick.yml
vendored
4
.github/workflows/gh-cherry-pick.yml
vendored
@@ -10,11 +10,11 @@ jobs:
|
||||
steps:
|
||||
- id: 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 != '' }}
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
|
||||
4
.github/workflows/gh-ghcr-retention.yml
vendored
4
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
|
||||
with:
|
||||
|
||||
2
.github/workflows/packages-npm-publish.yml
vendored
2
.github/workflows/packages-npm-publish.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
|
||||
10
.github/workflows/release-branch-off.yml
vendored
10
.github/workflows/release-branch-off.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
@@ -57,10 +57,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: release-bump-${{ inputs.next_version }}
|
||||
|
||||
23
.github/workflows/release-publish.yml
vendored
23
.github/workflows/release-publish.yml
vendored
@@ -87,11 +87,6 @@ jobs:
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -103,10 +98,10 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||
- name: Generate API Clients
|
||||
- name: make empty clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
@@ -160,21 +155,11 @@ jobs:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install web dependencies
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
npm run build-proxy
|
||||
- name: Build API client
|
||||
run: |
|
||||
make gen-client-go
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
|
||||
23
.github/workflows/release-tag.yml
vendored
23
.github/workflows/release-tag.yml
vendored
@@ -49,14 +49,8 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
@@ -67,10 +61,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
@@ -91,7 +85,6 @@ jobs:
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git pull
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
@@ -115,10 +108,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
@@ -137,7 +130,7 @@ jobs:
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
@@ -157,10 +150,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
@@ -192,7 +185,7 @@ jobs:
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
|
||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -15,10 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
steps:
|
||||
- id: generate_token
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
make web-check-compile
|
||||
- name: Create Pull Request
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: extract-compile-backend-translation
|
||||
|
||||
@@ -28,10 +28,8 @@ packages/django-channels-postgres @goauthentik/backend
|
||||
packages/django-postgres-cache @goauthentik/backend
|
||||
packages/django-dramatiq-postgres @goauthentik/backend
|
||||
# Web packages
|
||||
package.json @goauthentik/frontend
|
||||
package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
@@ -40,7 +38,7 @@ packages/tsconfig @goauthentik/frontend
|
||||
# Web
|
||||
web/ @goauthentik/frontend
|
||||
# Locale
|
||||
/locale/ @goauthentik/backend @goauthentik/frontend
|
||||
locale/ @goauthentik/backend @goauthentik/frontend
|
||||
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||
# Docs
|
||||
website/ @goauthentik/docs
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:4f9d98ebaa759f776496d850e0439c48948d587b191fc3949b5f5e4667abef90 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -44,7 +44,6 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -58,7 +57,6 @@ COPY ./go.mod /go/src/goauthentik.io/go.mod
|
||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
@@ -78,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.15@sha256:4c1ad814fe658851f50ff95ecd6948673fffddb0d7994bdb019dcb58227abd52 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
@@ -116,7 +114,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
# postgresql
|
||||
libpq-dev \
|
||||
# python-kadmin-rs
|
||||
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
|
||||
clang libkrb5-dev sccache \
|
||||
# xmlsec
|
||||
libltdl-dev && \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
@@ -158,11 +156,7 @@ WORKDIR /
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
# Required for runtime
|
||||
apt-get install -y --no-install-recommends \
|
||||
libpq5 libmaxminddb0 ca-certificates \
|
||||
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
|
||||
heimdal-multidev libkadm5clnt7t64-heimdal \
|
||||
libltdl7 libxslt1.1 && \
|
||||
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
|
||||
# Required for bootstrap & healtcheck
|
||||
apt-get install -y --no-install-recommends runit && \
|
||||
pip3 install --no-cache-dir --upgrade pip && \
|
||||
|
||||
19
Makefile
19
Makefile
@@ -119,11 +119,11 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
||||
ifndef version
|
||||
$(error Usage: make bump version=20xx.xx.xx )
|
||||
endif
|
||||
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
|
||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
sed -i "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
|
||||
|
||||
#########################
|
||||
@@ -188,15 +188,24 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
||||
|
||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||
mkdir -p ${PWD}/${GEN_API_PY}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_PY}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_PY} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||
|
||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||
gen-client-go: ## Build and install the authentik API for Golang
|
||||
mkdir -p ${PWD}/${GEN_API_GO}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_GO} && git reset --hard
|
||||
cd ${PWD}/${GEN_API_GO} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
||||
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
|
||||
make -C ${PWD}/${GEN_API_GO} build
|
||||
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
||||
|
||||
gen-dev-config: ## Generate a local development config file
|
||||
@@ -318,6 +327,6 @@ ci-pending-migrations: ci--meta-debug
|
||||
uv run ak makemigrations --check
|
||||
|
||||
ci-test: ci--meta-debug
|
||||
uv run coverage run manage.py test --keepdb authentik
|
||||
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
||||
uv run coverage report
|
||||
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 |
|
||||
| ---------- | ---------- |
|
||||
| 2025.8.x | ✅ |
|
||||
| 2025.10.x | ✅ |
|
||||
| 2025.12.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.12.4"
|
||||
VERSION = "2025.12.0-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
||||
|
||||
def get_version_latest(self, _) -> str:
|
||||
"""Get latest version from cache"""
|
||||
if get_current_tenant().schema_name != get_public_schema_name():
|
||||
if get_current_tenant().schema_name == get_public_schema_name():
|
||||
return authentik_version()
|
||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||
if not version_in_cache: # pragma: no cover
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import mimetypes
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema
|
||||
@@ -10,14 +12,13 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.admin.files.backends.base import get_content_type
|
||||
from authentik.admin.files.fields import FileField as AkFileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileApiUsage
|
||||
from authentik.admin.files.validation import validate_upload_file_name
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
|
||||
from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
@@ -25,6 +26,11 @@ from authentik.rbac.permissions import HasPermission
|
||||
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
|
||||
def get_mime_from_filename(filename: str) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return mime_type or "application/octet-stream"
|
||||
|
||||
|
||||
class FileView(APIView):
|
||||
pagination_class = None
|
||||
parser_classes = [MultiPartParser]
|
||||
@@ -47,7 +53,6 @@ class FileView(APIView):
|
||||
name = CharField()
|
||||
mime_type = CharField()
|
||||
url = CharField()
|
||||
themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileListParameters],
|
||||
@@ -75,9 +80,8 @@ class FileView(APIView):
|
||||
FileView.FileListSerializer(
|
||||
data={
|
||||
"name": file,
|
||||
"url": manager.file_url(file, request),
|
||||
"mime_type": get_content_type(file),
|
||||
"themed_urls": manager.themed_urls(file, request),
|
||||
"url": manager.file_url(file),
|
||||
"mime_type": get_mime_from_filename(file),
|
||||
}
|
||||
)
|
||||
for file in files
|
||||
@@ -146,7 +150,7 @@ class FileView(APIView):
|
||||
"pk": name,
|
||||
"name": name,
|
||||
"usage": usage.value,
|
||||
"mime_type": get_content_type(name),
|
||||
"mime_type": get_mime_from_filename(name),
|
||||
},
|
||||
).from_http(request)
|
||||
|
||||
@@ -236,9 +240,7 @@ class FileUsedByView(APIView):
|
||||
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 = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
import mimetypes
|
||||
from collections.abc import Callable, Generator, Iterator
|
||||
from typing import cast
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http.request import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
CACHE_PREFIX = "goauthentik.io/admin/files"
|
||||
LOGGER = get_logger()
|
||||
|
||||
# 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:
|
||||
"""
|
||||
@@ -83,48 +53,19 @@ class Backend:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
request: Optional Django HttpRequest for fully qualifed URL building
|
||||
use_cache: whether to retrieve the URL from cache
|
||||
|
||||
Returns:
|
||||
URL to access the file (may be relative or absolute depending on backend)
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@@ -191,22 +132,3 @@ class ManageableBackend(Backend):
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _cache_get_or_set(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None,
|
||||
default: Callable[[str, HttpRequest | None], str],
|
||||
timeout: int,
|
||||
) -> str:
|
||||
timeout_ignore = 60
|
||||
timeout = int(timeout * 0.67)
|
||||
if timeout < timeout_ignore:
|
||||
timeout = 0
|
||||
|
||||
request_key = "None"
|
||||
if request is not None:
|
||||
request_key = f"{request.build_absolute_uri('/')}"
|
||||
cache_key = f"{CACHE_PREFIX}/{self.name}/{self.usage}/{request_key}/{name}"
|
||||
|
||||
return cast(str, cache.get_or_set(cache_key, lambda: default(name, request), timeout))
|
||||
|
||||
@@ -45,13 +45,8 @@ class FileBackend(ManageableBackend):
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
# Check _base_dir (the mount point, e.g. /data) rather than base_path
|
||||
# (which includes usage/schema subdirs, e.g. /data/media/public).
|
||||
# The subdirectories are created on first file write via mkdir(parents=True)
|
||||
# in save_file(), so requiring them to exist beforehand would prevent
|
||||
# file creation on fresh installs.
|
||||
return (
|
||||
self._base_dir.exists()
|
||||
self.base_path.exists()
|
||||
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
|
||||
or (settings.DEBUG or settings.TEST)
|
||||
)
|
||||
@@ -68,12 +63,7 @@ class FileBackend(ManageableBackend):
|
||||
rel_path = full_path.relative_to(self.base_path)
|
||||
yield str(rel_path)
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for accessing the file."""
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
@@ -82,28 +72,21 @@ class FileBackend(ManageableBackend):
|
||||
)
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
if use_cache:
|
||||
timeout = int(expires_in.total_seconds())
|
||||
return self._cache_get_or_set(name, request, _file_url, timeout)
|
||||
else:
|
||||
return _file_url(name, request)
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to local filesystem."""
|
||||
|
||||
@@ -38,33 +38,6 @@ class PassthroughBackend(Backend):
|
||||
"""External files cannot be listed."""
|
||||
yield from []
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Return the URL as-is for passthrough files."""
|
||||
return name
|
||||
|
||||
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()}
|
||||
|
||||
@@ -9,7 +9,7 @@ from botocore.exceptions import ClientError
|
||||
from django.db import connection
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
@@ -130,72 +130,44 @@ class S3Backend(ManageableBackend):
|
||||
if rel_path: # Skip if it's just the directory itself
|
||||
yield rel_path
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Generate presigned URL for file access."""
|
||||
use_https = CONFIG.get_bool(
|
||||
f"storage.{self.usage.value}.{self.name}.secure_urls",
|
||||
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
|
||||
)
|
||||
|
||||
expires_in = int(
|
||||
timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
).total_seconds()
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in.total_seconds(),
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
HttpMethod="GET",
|
||||
)
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
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)
|
||||
return url
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to S3."""
|
||||
@@ -204,7 +176,6 @@ class S3Backend(ManageableBackend):
|
||||
Key=f"{self.base_path}/{name}",
|
||||
Body=content,
|
||||
ACL="private",
|
||||
ContentType=get_content_type(name),
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
@@ -220,7 +191,6 @@ class S3Backend(ManageableBackend):
|
||||
Key=f"{self.base_path}/{name}",
|
||||
ExtraArgs={
|
||||
"ACL": "private",
|
||||
"ContentType": get_content_type(name),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -44,12 +44,7 @@ class StaticBackend(Backend):
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for static file."""
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
url = f"{prefix}{name}"
|
||||
|
||||
@@ -165,31 +165,3 @@ class TestFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
def test_file_exists_false(self):
|
||||
"""Test file_exists returns False for nonexistent file"""
|
||||
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
|
||||
|
||||
def test_themed_urls_without_theme_variable(self):
|
||||
"""Test themed_urls returns None when filename has no %(theme)s"""
|
||||
file_name = "logo.png"
|
||||
result = self.backend.themed_urls(file_name)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_themed_urls_with_theme_variable(self):
|
||||
"""Test themed_urls returns dict of URLs for each theme"""
|
||||
file_name = "logo-%(theme)s.png"
|
||||
result = self.backend.themed_urls(file_name)
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn("light", result)
|
||||
self.assertIn("dark", result)
|
||||
|
||||
# Check URLs contain the substituted theme
|
||||
self.assertIn("logo-light.png", result["light"])
|
||||
self.assertIn("logo-dark.png", result["dark"])
|
||||
|
||||
def test_themed_urls_multiple_theme_variables(self):
|
||||
"""Test themed_urls with multiple %(theme)s in path"""
|
||||
file_name = "%(theme)s/logo-%(theme)s.svg"
|
||||
result = self.backend.themed_urls(file_name)
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn("light/logo-light.svg", result["light"])
|
||||
self.assertIn("dark/logo-dark.svg", result["dark"])
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
|
||||
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"""
|
||||
|
||||
@@ -110,106 +107,3 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3Backend with REPORTS usage"""
|
||||
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
|
||||
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
|
||||
|
||||
@CONFIG.patch("storage.s3.secure_urls", True)
|
||||
@CONFIG.patch("storage.s3.addressing_style", "path")
|
||||
def test_file_url_custom_domain_with_bucket_no_duplicate(self):
|
||||
"""Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
|
||||
|
||||
Regression test for https://github.com/goauthentik/authentik/issues/19521
|
||||
|
||||
When using:
|
||||
- Path-style addressing (bucket name goes in URL path, not subdomain)
|
||||
- Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
|
||||
|
||||
The bucket name should NOT appear twice in the final URL.
|
||||
|
||||
Example of the bug:
|
||||
- custom_domain = "s3.example.com/authentik-media"
|
||||
- boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
|
||||
- Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
|
||||
"""
|
||||
bucket_name = self.media_s3_bucket_name
|
||||
|
||||
# Custom domain includes the bucket name
|
||||
custom_domain = f"localhost:8020/{bucket_name}"
|
||||
|
||||
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
|
||||
url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
|
||||
|
||||
# The bucket name should appear exactly once in the URL path, not twice
|
||||
bucket_occurrences = url.count(bucket_name)
|
||||
self.assertEqual(
|
||||
bucket_occurrences,
|
||||
1,
|
||||
f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
|
||||
f"URL: {url}",
|
||||
)
|
||||
|
||||
def test_themed_urls_without_theme_variable(self):
|
||||
"""Test themed_urls returns None when filename has no %(theme)s"""
|
||||
result = self.media_s3_backend.themed_urls("logo.png")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_themed_urls_with_theme_variable(self):
|
||||
"""Test themed_urls returns dict of presigned URLs for each theme"""
|
||||
result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn("light", result)
|
||||
self.assertIn("dark", result)
|
||||
|
||||
# Check URLs are valid presigned URLs with correct file paths
|
||||
self.assertIn("logo-light.png", result["light"])
|
||||
self.assertIn("logo-dark.png", result["dark"])
|
||||
self.assertIn("X-Amz-Signature=", result["light"])
|
||||
self.assertIn("X-Amz-Signature=", result["dark"])
|
||||
|
||||
def test_themed_urls_multiple_theme_variables(self):
|
||||
"""Test themed_urls with multiple %(theme)s in path"""
|
||||
result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn("light/logo-light.svg", result["light"])
|
||||
self.assertIn("dark/logo-dark.svg", result["dark"])
|
||||
|
||||
def test_save_file_sets_content_type_svg(self):
|
||||
"""Test save_file sets correct ContentType for SVG files"""
|
||||
self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
|
||||
|
||||
response = self.media_s3_backend.client.head_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.svg",
|
||||
)
|
||||
self.assertEqual(response["ContentType"], "image/svg+xml")
|
||||
|
||||
def test_save_file_sets_content_type_png(self):
|
||||
"""Test save_file sets correct ContentType for PNG files"""
|
||||
self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
response = self.media_s3_backend.client.head_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.png",
|
||||
)
|
||||
self.assertEqual(response["ContentType"], "image/png")
|
||||
|
||||
def test_save_file_stream_sets_content_type(self):
|
||||
"""Test save_file_stream sets correct ContentType"""
|
||||
with self.media_s3_backend.save_file_stream("test.css") as f:
|
||||
f.write(b"body { color: red; }")
|
||||
|
||||
response = self.media_s3_backend.client.head_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.css",
|
||||
)
|
||||
self.assertEqual(response["ContentType"], "text/css")
|
||||
|
||||
def test_save_file_unknown_extension_octet_stream(self):
|
||||
"""Test save_file sets octet-stream for unknown extensions"""
|
||||
self.media_s3_backend.save_file("test.unknownext123", b"data")
|
||||
|
||||
response = self.media_s3_backend.client.head_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.unknownext123",
|
||||
)
|
||||
self.assertEqual(response["ContentType"], "application/octet-stream")
|
||||
|
||||
@@ -70,7 +70,6 @@ class FileManager:
|
||||
self,
|
||||
name: str | None,
|
||||
request: HttpRequest | Request | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
@@ -88,28 +87,6 @@ class FileManager:
|
||||
LOGGER.warning(f"Could not find file backend for file: {name}")
|
||||
return ""
|
||||
|
||||
def themed_urls(
|
||||
self,
|
||||
name: str | None,
|
||||
request: HttpRequest | Request | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""
|
||||
Get URLs for each theme variant when filename contains %(theme)s.
|
||||
|
||||
Returns dict mapping theme to URL if %(theme)s present, None otherwise.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
if isinstance(request, Request):
|
||||
request = request._request
|
||||
|
||||
for backend in self.backends:
|
||||
if backend.supports_file(name):
|
||||
return backend.themed_urls(name, request)
|
||||
|
||||
return None
|
||||
|
||||
def _check_manageable(self) -> None:
|
||||
if not self.manageable:
|
||||
raise ImproperlyConfigured("No file management backend configured.")
|
||||
|
||||
@@ -5,6 +5,7 @@ from io import BytesIO
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.admin.files.api import get_mime_from_filename
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
@@ -93,9 +94,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "http://testserver/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
"themed_urls": None,
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
@@ -129,9 +129,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "http://testserver/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
"themed_urls": None,
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
@@ -201,64 +200,30 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("field is required", str(response.data))
|
||||
|
||||
def test_list_files_includes_themed_urls_none(self):
|
||||
"""Test listing files includes themed_urls as None for non-themed files"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "test-no-theme.png"
|
||||
manager.save_file(file_name, b"test content")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:files", query={"search": file_name, "manageableOnly": "true"})
|
||||
)
|
||||
class TestGetMimeFromFilename(TestCase):
|
||||
"""Test get_mime_from_filename function"""
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
file_entry = next((f for f in response.data if f["name"] == file_name), None)
|
||||
self.assertIsNotNone(file_entry)
|
||||
self.assertIn("themed_urls", file_entry)
|
||||
self.assertIsNone(file_entry["themed_urls"])
|
||||
def test_image_png(self):
|
||||
"""Test PNG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
|
||||
|
||||
manager.delete_file(file_name)
|
||||
def test_image_jpeg(self):
|
||||
"""Test JPEG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
|
||||
|
||||
def test_list_files_includes_themed_urls_dict(self):
|
||||
"""Test listing files includes themed_urls as dict for themed files"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "logo-%(theme)s.svg"
|
||||
manager.save_file("logo-light.svg", b"<svg>light</svg>")
|
||||
manager.save_file("logo-dark.svg", b"<svg>dark</svg>")
|
||||
manager.save_file(file_name, b"<svg>placeholder</svg>")
|
||||
def test_image_svg(self):
|
||||
"""Test SVG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:files", query={"search": "%(theme)s", "manageableOnly": "true"})
|
||||
)
|
||||
def test_text_plain(self):
|
||||
"""Test text file MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
file_entry = next((f for f in response.data if f["name"] == file_name), None)
|
||||
self.assertIsNotNone(file_entry)
|
||||
self.assertIn("themed_urls", file_entry)
|
||||
self.assertIsInstance(file_entry["themed_urls"], dict)
|
||||
self.assertIn("light", file_entry["themed_urls"])
|
||||
self.assertIn("dark", file_entry["themed_urls"])
|
||||
def test_unknown_extension(self):
|
||||
"""Test unknown extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
|
||||
|
||||
manager.delete_file(file_name)
|
||||
manager.delete_file("logo-light.svg")
|
||||
manager.delete_file("logo-dark.svg")
|
||||
|
||||
def test_upload_file_with_theme_variable(self):
|
||||
"""Test uploading file with %(theme)s in name"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "brand-logo-%(theme)s.svg"
|
||||
file_content = b"<svg></svg>"
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(manager.file_exists(file_name))
|
||||
manager.delete_file(file_name)
|
||||
def test_no_extension(self):
|
||||
"""Test no extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
"""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.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
@@ -88,7 +81,6 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
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)
|
||||
@@ -105,71 +97,3 @@ class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
|
||||
# S3 URLs should be returned as-is (already absolute)
|
||||
self.assertTrue(result.startswith("http://s3.test:8080/test"))
|
||||
|
||||
|
||||
class TestThemedUrls(FileTestFileBackendMixin, TestCase):
|
||||
"""Test FileManager.themed_urls method"""
|
||||
|
||||
def test_themed_urls_none_path(self):
|
||||
"""Test themed_urls returns None for None path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.themed_urls(None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_themed_urls_empty_path(self):
|
||||
"""Test themed_urls returns None for empty path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.themed_urls("")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_themed_urls_no_theme_variable(self):
|
||||
"""Test themed_urls returns None when no %(theme)s in path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.themed_urls("logo.png")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_themed_urls_with_theme_variable(self):
|
||||
"""Test themed_urls returns dict of URLs for each theme"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.themed_urls("logo-%(theme)s.png")
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn("light", result)
|
||||
self.assertIn("dark", result)
|
||||
self.assertIn("logo-light.png", result["light"])
|
||||
self.assertIn("logo-dark.png", result["dark"])
|
||||
|
||||
def test_themed_urls_with_request(self):
|
||||
"""Test themed_urls builds absolute URLs with request"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.themed_urls("logo-%(theme)s.svg", mock_request)
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
light_url = urlparse(result["light"])
|
||||
dark_url = urlparse(result["dark"])
|
||||
self.assertEqual(light_url.scheme, "http")
|
||||
self.assertEqual(light_url.netloc, "example.com")
|
||||
self.assertEqual(dark_url.scheme, "http")
|
||||
self.assertEqual(dark_url.netloc, "example.com")
|
||||
|
||||
def test_themed_urls_passthrough_with_theme_variable(self):
|
||||
"""Test themed_urls returns dict for passthrough URLs with %(theme)s"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
# External URLs with %(theme)s should return themed URLs
|
||||
result = manager.themed_urls("https://example.com/logo-%(theme)s.png")
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertEqual(result["light"], "https://example.com/logo-light.png")
|
||||
self.assertEqual(result["dark"], "https://example.com/logo-dark.png")
|
||||
|
||||
def test_themed_urls_passthrough_without_theme_variable(self):
|
||||
"""Test themed_urls returns None for passthrough URLs without %(theme)s"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
# External URLs without %(theme)s should return None
|
||||
result = manager.themed_urls("https://example.com/logo.png")
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -62,10 +62,10 @@ class TestSanitizeFilePath(TestCase):
|
||||
"test@file.png", # @
|
||||
"test#file.png", # #
|
||||
"test$file.png", # $
|
||||
"test%file.png", # % (but %(theme)s is allowed)
|
||||
"test%file.png", # %
|
||||
"test&file.png", # &
|
||||
"test*file.png", # *
|
||||
"test(file).png", # parentheses (but %(theme)s is allowed)
|
||||
"test(file).png", # parentheses
|
||||
"test[file].png", # brackets
|
||||
"test{file}.png", # braces
|
||||
]
|
||||
@@ -108,30 +108,3 @@ class TestSanitizeFilePath(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
def test_sanitize_theme_variable_valid(self):
|
||||
"""Test sanitizing filename with %(theme)s variable"""
|
||||
# These should all be valid
|
||||
validate_file_name("logo-%(theme)s.png")
|
||||
validate_file_name("brand/logo-%(theme)s.svg")
|
||||
validate_file_name("images/icon-%(theme)s.png")
|
||||
validate_file_name("%(theme)s/logo.png")
|
||||
validate_file_name("brand/%(theme)s/logo.png")
|
||||
|
||||
def test_sanitize_theme_variable_multiple(self):
|
||||
"""Test sanitizing filename with multiple %(theme)s variables"""
|
||||
validate_file_name("%(theme)s/logo-%(theme)s.png")
|
||||
|
||||
def test_sanitize_theme_variable_invalid_format(self):
|
||||
"""Test that partial or malformed theme variables are rejected"""
|
||||
invalid_paths = [
|
||||
"test%(theme.png", # missing )s
|
||||
"test%theme)s.png", # missing (
|
||||
"test%(themes).png", # wrong variable name
|
||||
"test%(THEME)s.png", # wrong case
|
||||
"test%()s.png", # empty variable name
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
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):
|
||||
@@ -72,7 +57,7 @@ class FileTestS3BackendMixin:
|
||||
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.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
@@ -85,7 +70,7 @@ class FileTestS3BackendMixin:
|
||||
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.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
|
||||
@@ -4,7 +4,6 @@ from pathlib import PurePosixPath
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.admin.files.backends.base import THEME_VARIABLE
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
@@ -40,17 +39,12 @@ def validate_upload_file_name(
|
||||
if not name:
|
||||
raise ValidationError(_("File name cannot be empty"))
|
||||
|
||||
# Allow %(theme)s placeholder for theme-specific files
|
||||
# Replace with placeholder for validation, then check the result
|
||||
name_for_validation = name.replace(THEME_VARIABLE, "theme")
|
||||
|
||||
# Same regex is used in the frontend as well (with %(theme)s handling)
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
|
||||
# Same regex is used in the frontend as well
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
|
||||
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
|
||||
"and the placeholder %(theme)s for theme-specific files"
|
||||
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -70,9 +70,6 @@ class IPCUser(AnonymousUser):
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def all_roles(self):
|
||||
return []
|
||||
|
||||
|
||||
class TokenAuthentication(BaseAuthentication):
|
||||
"""Token-based authentication using HTTP Bearer authentication"""
|
||||
|
||||
@@ -13,13 +13,6 @@ class Pagination(pagination.PageNumberPagination):
|
||||
page_query_param = "page"
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
page_size = super().get_page_size(request)
|
||||
if page_size is not None:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
previous_page_number = 0
|
||||
if self.page.has_previous():
|
||||
|
||||
@@ -31,7 +31,6 @@ class Capabilities(models.TextChoices):
|
||||
"""Define capabilities which influence which APIs can/should be used"""
|
||||
|
||||
CAN_SAVE_MEDIA = "can_save_media"
|
||||
CAN_SAVE_REPORTS = "can_save_reports"
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_ASN = "can_asn"
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
@@ -71,8 +70,6 @@ class ConfigView(APIView):
|
||||
caps = []
|
||||
if get_file_manager(FileUsage.MEDIA).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
if get_file_manager(FileUsage.REPORTS).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_REPORTS)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
caps.append(cap)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""authentik Blueprints app"""
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from inspect import ismethod
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from dramatiq.broker import get_broker
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -46,21 +44,8 @@ class ManagedAppConfig(AppConfig):
|
||||
module_name = f"{self.name}.{rel_module}"
|
||||
import_module(module_name)
|
||||
self.logger.info("Imported related module", module=module_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
if settings.DEBUG:
|
||||
# This is a heuristic for determining whether the exception was caused
|
||||
# "directly" by the `import_module` call or whether the initial import
|
||||
# succeeded and a later import (within the existing module) failed.
|
||||
# 1. <the calling function>
|
||||
# 2. importlib.import_module
|
||||
# 3. importlib._bootstrap._gcd_import
|
||||
# 4. importlib._bootstrap._find_and_load
|
||||
# 5. importlib._bootstrap._find_and_load_unlocked
|
||||
STACK_LENGTH_HEURISTIC = 5
|
||||
|
||||
stack_length = len(traceback.extract_tb(exc.__traceback__))
|
||||
if stack_length > STACK_LENGTH_HEURISTIC:
|
||||
raise
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import_relative("checks")
|
||||
import_relative("tasks")
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from django.db.models import Model, fields
|
||||
from django.db.models.fields.related import OneToOneField
|
||||
from drf_jsonschema_serializer.convert import converter, field_to_converter
|
||||
from rest_framework.fields import Field, JSONField, UUIDField
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
@@ -33,8 +32,6 @@ class PrimaryKeyRelatedFieldConverter:
|
||||
def convert(self, field: PrimaryKeyRelatedField):
|
||||
model: Model = field.queryset.model
|
||||
pk_field = model._meta.pk
|
||||
if isinstance(pk_field, OneToOneField):
|
||||
pk_field = pk_field.related_fields[0][1]
|
||||
if isinstance(pk_field, fields.UUIDField):
|
||||
return {"type": "string", "format": "uuid"}
|
||||
return {"type": "integer"}
|
||||
|
||||
@@ -8,62 +8,45 @@ metadata:
|
||||
- Application (icon)
|
||||
- Source (icon)
|
||||
- Flow (background)
|
||||
- Endpoint Enrollment token (key)
|
||||
entries:
|
||||
token:
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
app:
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
source:
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
flow:
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
user:
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
endpoint:
|
||||
- model: authentik_endpoints_connectors_agent.agentconnector
|
||||
id: connector
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
- model: authentik_endpoints_connectors_agent.enrollmenttoken
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
connector: !KeyOf connector
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.test import TransactionTestCase
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import Token, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.endpoints.connectors.agent.models import EnrollmentToken
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
|
||||
@@ -30,18 +29,12 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
|
||||
def test_user(self):
|
||||
"""Test user"""
|
||||
user = User.objects.filter(username=self.uid).first()
|
||||
user: User = User.objects.filter(username=self.uid).first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertTrue(user.check_password(self.uid))
|
||||
|
||||
def test_user_null(self):
|
||||
"""Test user"""
|
||||
user = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
def test_enrollment_token(self):
|
||||
"""Test endpoint enrollment token"""
|
||||
token = EnrollmentToken.objects.filter(name=self.uid).first()
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(token.key, self.uid)
|
||||
|
||||
@@ -36,7 +36,10 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
||||
self.assertTrue(importer.apply())
|
||||
role = Role.objects.filter(name=uid).first()
|
||||
self.assertIsNotNone(role)
|
||||
self.assertEqual(get_perms(role), {"authentik_blueprints.view_blueprintinstance"})
|
||||
self.assertEqual(
|
||||
list(role.group.permissions.all().values_list("codename", flat=True)),
|
||||
["view_blueprintinstance"],
|
||||
)
|
||||
|
||||
def test_object_permission(self):
|
||||
"""Test permissions"""
|
||||
@@ -50,5 +53,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
||||
user = User.objects.filter(username=uid).first()
|
||||
role = Role.objects.filter(name=uid).first()
|
||||
self.assertIsNotNone(flow)
|
||||
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
|
||||
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})
|
||||
self.assertEqual(get_perms(user, flow), ["view_flow"])
|
||||
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
||||
instance.status,
|
||||
BlueprintInstanceStatus.UNKNOWN,
|
||||
)
|
||||
apply_blueprint.send(instance.pk).get_result(block=True)
|
||||
apply_blueprint(instance.pk)
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.last_applied_hash, "")
|
||||
self.assertEqual(
|
||||
|
||||
@@ -16,7 +16,8 @@ from django.db.models.query_utils import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django_channels_postgres.models import GroupChannel, Message
|
||||
from guardian.models import RoleObjectPermission, UserObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -109,7 +110,6 @@ def excluded_models() -> list[type[Model]]:
|
||||
DjangoGroup,
|
||||
ContentType,
|
||||
Permission,
|
||||
RoleObjectPermission,
|
||||
UserObjectPermission,
|
||||
# Base classes
|
||||
Provider,
|
||||
@@ -394,12 +394,10 @@ class Importer:
|
||||
"""Apply object-level permissions for an entry"""
|
||||
for perm in entry.get_permissions(self._import):
|
||||
if perm.user is not None:
|
||||
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
|
||||
perm.permission, instance
|
||||
)
|
||||
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
|
||||
if perm.role is not None:
|
||||
role = Role.objects.get(pk=perm.role)
|
||||
role.assign_perms(perm.permission, obj=instance)
|
||||
role.assign_permission(perm.permission, obj=instance)
|
||||
|
||||
def apply(self) -> bool:
|
||||
"""Apply (create/update) models yaml, in database transaction"""
|
||||
|
||||
@@ -37,21 +37,14 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data: dict) -> MetaResult:
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.tasks import apply_blueprint
|
||||
|
||||
if not self.blueprint_instance:
|
||||
LOGGER.info("Blueprint does not exist, but not required")
|
||||
return MetaResult()
|
||||
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
||||
|
||||
# Apply blueprint directly using Importer to avoid task context requirements
|
||||
# and prevent deadlocks when called from within another blueprint task
|
||||
blueprint_content = self.blueprint_instance.retrieve()
|
||||
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
|
||||
valid, logs = importer.validate()
|
||||
[log.log() for log in logs]
|
||||
if valid:
|
||||
importer.apply()
|
||||
apply_blueprint(self.blueprint_instance.pk)
|
||||
return MetaResult()
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
|
||||
from dramatiq.actor import actor
|
||||
from dramatiq.middleware import Middleware
|
||||
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.tasks.apps import PRIORITY_HIGH
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -189,7 +191,10 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||
|
||||
@actor(description=_("Apply single blueprint."))
|
||||
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))
|
||||
instance: BlueprintInstance | None = None
|
||||
try:
|
||||
|
||||
@@ -6,12 +6,7 @@ from django.db import models
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
ChoiceField,
|
||||
ListField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
@@ -21,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.rbac.filters import SecretKeyFilter
|
||||
from authentik.tenants.api.settings import FlagJSONField
|
||||
from authentik.tenants.flags import Flag
|
||||
@@ -95,9 +90,7 @@ class CurrentBrandSerializer(PassiveSerializer):
|
||||
matched_domain = CharField(source="domain")
|
||||
branding_title = CharField()
|
||||
branding_logo = CharField(source="branding_logo_url")
|
||||
branding_logo_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
||||
branding_favicon = CharField(source="branding_favicon_url")
|
||||
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
||||
branding_custom_css = CharField()
|
||||
ui_footer_links = ListField(
|
||||
child=FooterLinkSerializer(),
|
||||
|
||||
@@ -89,26 +89,14 @@ class Brand(SerializerModel):
|
||||
"""Get branding_logo URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
|
||||
|
||||
def branding_logo_themed_urls(self) -> dict[str, str] | None:
|
||||
"""Get themed URLs for branding_logo if it contains %(theme)s"""
|
||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_logo)
|
||||
|
||||
def branding_favicon_url(self) -> str:
|
||||
"""Get branding_favicon URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
|
||||
|
||||
def branding_favicon_themed_urls(self) -> dict[str, str] | None:
|
||||
"""Get themed URLs for branding_favicon if it contains %(theme)s"""
|
||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_favicon)
|
||||
|
||||
def branding_default_flow_background_url(self) -> str:
|
||||
"""Get branding_default_flow_background URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
|
||||
|
||||
def branding_default_flow_background_themed_urls(self) -> dict[str, str] | None:
|
||||
"""Get themed URLs for branding_default_flow_background if it contains %(theme)s"""
|
||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_default_flow_background)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.brands.api import BrandSerializer
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.brands.api import Themes
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
|
||||
@@ -34,14 +35,12 @@ class TestBrands(APITestCase):
|
||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": brand.domain,
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -56,14 +55,12 @@ class TestBrands(APITestCase):
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -75,14 +72,12 @@ class TestBrands(APITestCase):
|
||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "fallback",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -99,14 +94,12 @@ class TestBrands(APITestCase):
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -124,14 +117,12 @@ class TestBrands(APITestCase):
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -142,14 +133,12 @@ class TestBrands(APITestCase):
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -165,14 +154,12 @@ class TestBrands(APITestCase):
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom-strong",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -188,14 +175,12 @@ class TestBrands(APITestCase):
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom-weak",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
@@ -271,14 +256,12 @@ class TestBrands(APITestCase):
|
||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||
{
|
||||
"branding_logo": "https://goauthentik.io/img/icon.png",
|
||||
"branding_logo_themed_urls": None,
|
||||
"branding_favicon": "https://goauthentik.io/img/icon.png",
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": brand.domain,
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
|
||||
@@ -4,8 +4,7 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -23,8 +22,7 @@ from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
@@ -53,28 +51,13 @@ class ApplicationSerializer(ModelSerializer):
|
||||
)
|
||||
|
||||
meta_icon_url = ReadOnlyField(source="get_meta_icon")
|
||||
meta_icon_themed_urls = ThemedUrlsSerializer(
|
||||
source="get_meta_icon_themed_urls", read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
user = None
|
||||
user_data = None
|
||||
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
|
||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
||||
# for multiple applications. UserSerializer accesses user.ak_groups which
|
||||
# would otherwise trigger a query for each application.
|
||||
if user is not None:
|
||||
if "_cached_user_data" not in self.context:
|
||||
# Prefetch groups to avoid N+1
|
||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
||||
user_data = self.context["_cached_user_data"]
|
||||
|
||||
return app.get_launch_url(user, user_data=user_data)
|
||||
return app.get_launch_url(user)
|
||||
|
||||
def validate_slug(self, slug: str) -> str:
|
||||
if slug in Application.reserved_slugs:
|
||||
@@ -105,7 +88,6 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"meta_launch_url",
|
||||
"meta_icon",
|
||||
"meta_icon_url",
|
||||
"meta_icon_themed_urls",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"policy_engine_mode",
|
||||
@@ -168,23 +150,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
applications.append(application)
|
||||
return applications
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
Re-fetch with proper prefetching for serialization
|
||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
||||
during serialization when get_provider() is called
|
||||
"""
|
||||
if not applications:
|
||||
return self.get_queryset().none()
|
||||
pks = [app.pk for app in applications]
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(pk__in=pks)
|
||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
self, paginated_apps: Iterator[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in paginated_apps:
|
||||
@@ -287,8 +254,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@@ -307,7 +272,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
@@ -78,7 +78,7 @@ class AdminDeviceViewSet(ViewSet):
|
||||
"""Get all devices in all child classes"""
|
||||
for model in device_classes():
|
||||
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)
|
||||
yield from device_set
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
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.response import Response
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
@@ -33,16 +33,6 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class PartialUserSerializer(ModelSerializer):
|
||||
"""Partial User Serializer, does not include child relations."""
|
||||
@@ -52,11 +42,20 @@ class PartialUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
|
||||
|
||||
class RelatedGroupSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children/parents for groups"""
|
||||
class GroupChildSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children for groups"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
|
||||
@@ -75,17 +74,15 @@ class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
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)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
roles_obj = ListSerializer(
|
||||
child=RoleSerializer(),
|
||||
read_only=True,
|
||||
source="roles",
|
||||
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)
|
||||
|
||||
@property
|
||||
@@ -102,46 +99,25 @@ class GroupSerializer(ModelSerializer):
|
||||
return 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))
|
||||
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
return None
|
||||
return PartialUserSerializer(instance.users, many=True).data
|
||||
|
||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
||||
@extend_schema_field(GroupChildSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
|
||||
if not self._should_include_children:
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.children, many=True).data
|
||||
return GroupChildSerializer(instance.children, many=True).data
|
||||
|
||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
||||
def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
||||
if not self._should_include_parents:
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.parents, many=True).data
|
||||
|
||||
@extend_schema_field(RoleSerializer(many=True))
|
||||
def get_inherited_roles_obj(self, instance: Group) -> list | None:
|
||||
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
|
||||
if not self._should_include_inherited_roles:
|
||||
return None
|
||||
direct_role_pks = instance.roles.values_list("pk", flat=True)
|
||||
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
|
||||
return RoleSerializer(inherited_roles, many=True).data
|
||||
def validate_parent(self, parent: Group | None):
|
||||
"""Validate group parent (if set), ensuring the parent isn't itself"""
|
||||
if not self.instance or not parent:
|
||||
return parent
|
||||
if str(parent.group_uuid) == str(self.instance.group_uuid):
|
||||
raise ValidationError(_("Cannot set group as parent of itself."))
|
||||
return parent
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
@@ -177,14 +153,13 @@ class GroupSerializer(ModelSerializer):
|
||||
"num_pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parents",
|
||||
"parents_obj",
|
||||
"parent",
|
||||
"parent_name",
|
||||
"users",
|
||||
"users_obj",
|
||||
"attributes",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"inherited_roles_obj",
|
||||
"children",
|
||||
"children_obj",
|
||||
]
|
||||
@@ -196,10 +171,9 @@ class GroupSerializer(ModelSerializer):
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
"parents": {
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
# TODO: This field isn't unique on the database which is hard to backport
|
||||
# hence we just validate the uniqueness here
|
||||
"name": {"validators": [UniqueValidator(Group.objects.all())]},
|
||||
}
|
||||
|
||||
|
||||
@@ -274,21 +248,14 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
return [
|
||||
StrField(Group, "name"),
|
||||
BoolField(Group, "is_superuser", nullable=True),
|
||||
JSONSearchField(Group, "attributes"),
|
||||
JSONSearchField(Group, "attributes", suggest_nested=False),
|
||||
]
|
||||
|
||||
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:
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
# time
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch(
|
||||
"users",
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
base_qs = base_qs.prefetch_related("users")
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
@@ -297,17 +264,12 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
if self.serializer_class(context={"request": self.request})._should_include_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
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -317,8 +279,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
@@ -18,14 +18,10 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||
assigned_backchannel_application_slug = ReadOnlyField(
|
||||
source="backchannel_application.slug", allow_null=True
|
||||
)
|
||||
assigned_backchannel_application_name = ReadOnlyField(
|
||||
source="backchannel_application.name", allow_null=True
|
||||
)
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||
assigned_application_name = ReadOnlyField(source="application.name")
|
||||
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
||||
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
||||
|
||||
component = SerializerMethodField()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
|
||||
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
@@ -28,7 +28,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
managed = ReadOnlyField()
|
||||
component = SerializerMethodField()
|
||||
icon_url = ReadOnlyField()
|
||||
icon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
||||
|
||||
def get_component(self, obj: Source) -> str:
|
||||
"""Get object component so that we know how to edit the object"""
|
||||
@@ -58,7 +57,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"user_path_template",
|
||||
"icon",
|
||||
"icon_url",
|
||||
"icon_themed_urls",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@@ -76,8 +76,7 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
expires = attrs.get("expires")
|
||||
if expires is not None and expires > max_token_lifetime_dt:
|
||||
if "expires" in attrs and attrs.get("expires") > max_token_lifetime_dt:
|
||||
raise ValidationError(
|
||||
{
|
||||
"expires": (
|
||||
@@ -158,9 +157,7 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
user=self.request.user,
|
||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||
)
|
||||
self.request.user.assign_perms_to_managed_role(
|
||||
"authentik_core.view_token_key", instance
|
||||
)
|
||||
assign_perm("authentik_core.view_token_key", self.request.user, instance)
|
||||
return instance
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class UsedByMixin:
|
||||
# query and check if there is a difference between modes the user can see
|
||||
# and can't see and add a warning
|
||||
for obj in get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", manager.all()
|
||||
request.user, f"{app}.view_{model_name}", manager
|
||||
).all():
|
||||
# Only merge shadows on first object
|
||||
if first_object:
|
||||
|
||||
@@ -86,10 +86,8 @@ from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
from authentik.rbac.models import get_permission_choices
|
||||
from authentik.stages.email.flow import pickle_flow_token_for_email
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
@@ -108,6 +106,7 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
"""Partial Group Serializer, does not include child relations."""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
@@ -116,6 +115,8 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
"num_pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parent",
|
||||
"parent_name",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
@@ -134,13 +135,6 @@ class UserSerializer(ModelSerializer):
|
||||
default=list,
|
||||
)
|
||||
groups_obj = SerializerMethodField(allow_null=True)
|
||||
roles = PrimaryKeyRelatedField(
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
default=list,
|
||||
)
|
||||
roles_obj = SerializerMethodField(allow_null=True)
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(
|
||||
max_length=150,
|
||||
@@ -154,25 +148,12 @@ class UserSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_groups", "true")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_roles(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_roles", "true")).lower() == "true"
|
||||
|
||||
@extend_schema_field(PartialGroupSerializer(many=True))
|
||||
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
|
||||
if not self._should_include_groups:
|
||||
return None
|
||||
return PartialGroupSerializer(instance.ak_groups, many=True).data
|
||||
|
||||
@extend_schema_field(RoleSerializer(many=True))
|
||||
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
|
||||
if not self._should_include_roles:
|
||||
return None
|
||||
return RoleSerializer(instance.roles, many=True).data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
@@ -187,26 +168,24 @@ class UserSerializer(ModelSerializer):
|
||||
directly setting a password. However should be done via the `set_password`
|
||||
method instead of directly setting it like rest_framework."""
|
||||
password = validated_data.pop("password", None)
|
||||
perms_qs = Permission.objects.filter(
|
||||
permissions = Permission.objects.filter(
|
||||
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
|
||||
).values_list("content_type__app_label", "codename")
|
||||
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
|
||||
)
|
||||
validated_data["user_permissions"] = permissions
|
||||
instance: User = super().create(validated_data)
|
||||
self._set_password(instance, password)
|
||||
instance.assign_perms_to_managed_role(perms_list)
|
||||
return instance
|
||||
|
||||
def update(self, instance: User, validated_data: dict) -> User:
|
||||
"""Same as `create` above, set the password directly if we're in a blueprint
|
||||
context"""
|
||||
password = validated_data.pop("password", None)
|
||||
perms_qs = Permission.objects.filter(
|
||||
permissions = Permission.objects.filter(
|
||||
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
|
||||
).values_list("content_type__app_label", "codename")
|
||||
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
|
||||
)
|
||||
validated_data["user_permissions"] = permissions
|
||||
instance = super().update(instance, validated_data)
|
||||
self._set_password(instance, password)
|
||||
instance.assign_perms_to_managed_role(perms_list)
|
||||
return instance
|
||||
|
||||
def _set_password(self, instance: User, password: str | None):
|
||||
@@ -261,8 +240,6 @@ class UserSerializer(ModelSerializer):
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"groups_obj",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"email",
|
||||
"avatar",
|
||||
"attributes",
|
||||
@@ -286,7 +263,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
avatar = SerializerMethodField()
|
||||
groups = SerializerMethodField()
|
||||
roles = SerializerMethodField()
|
||||
uid = CharField(read_only=True)
|
||||
settings = SerializerMethodField()
|
||||
system_permissions = SerializerMethodField()
|
||||
@@ -314,25 +290,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"pk": group.pk,
|
||||
}
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
child=inline_serializer(
|
||||
"UserSelfRoles",
|
||||
{
|
||||
"name": CharField(read_only=True),
|
||||
"pk": CharField(read_only=True),
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
def get_roles(self, _: User):
|
||||
"""Return only the roles a user is member of"""
|
||||
for role in self.instance.all_roles().order_by("name"):
|
||||
yield {
|
||||
"name": role.name,
|
||||
"pk": role.pk,
|
||||
}
|
||||
|
||||
def get_settings(self, user: User) -> dict[str, Any]:
|
||||
"""Get user settings with brand and group settings applied"""
|
||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||
@@ -354,7 +311,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"roles",
|
||||
"email",
|
||||
"avatar",
|
||||
"uid",
|
||||
@@ -434,16 +390,6 @@ class UsersFilter(FilterSet):
|
||||
queryset=Group.objects.all().order_by("name"),
|
||||
)
|
||||
|
||||
roles_by_name = ModelMultipleChoiceFilter(
|
||||
field_name="roles__name",
|
||||
to_field_name="name",
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
)
|
||||
roles_by_pk = ModelMultipleChoiceFilter(
|
||||
field_name="roles",
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
)
|
||||
|
||||
def filter_is_superuser(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(ak_groups__is_superuser=True).distinct()
|
||||
@@ -479,17 +425,11 @@ class UsersFilter(FilterSet):
|
||||
"attributes",
|
||||
"groups_by_name",
|
||||
"groups_by_pk",
|
||||
"roles_by_name",
|
||||
"roles_by_pk",
|
||||
"type",
|
||||
]
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
@@ -518,21 +458,18 @@ class UserViewSet(
|
||||
StrField(User, "path"),
|
||||
BoolField(User, "is_active", nullable=True),
|
||||
ChoiceSearchField(User, "type"),
|
||||
JSONSearchField(User, "attributes"),
|
||||
JSONSearchField(User, "attributes", suggest_nested=False),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
base_qs = User.objects.all().exclude_anonymous()
|
||||
if self.serializer_class(context={"request": self.request})._should_include_groups:
|
||||
base_qs = base_qs.prefetch_related("ak_groups")
|
||||
if self.serializer_class(context={"request": self.request})._should_include_roles:
|
||||
base_qs = base_qs.prefetch_related("roles")
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_groups", bool, default=True),
|
||||
OpenApiParameter("include_roles", bool, default=True),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
@@ -127,10 +127,3 @@ class LinkSerializer(PassiveSerializer):
|
||||
"""Returns a single link"""
|
||||
|
||||
link = CharField()
|
||||
|
||||
|
||||
class ThemedUrlsSerializer(PassiveSerializer):
|
||||
"""Themed URLs - maps theme names to URLs for light and dark themes"""
|
||||
|
||||
light = CharField(required=False, allow_null=True)
|
||||
dark = CharField(required=False, allow_null=True)
|
||||
|
||||
@@ -12,27 +12,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class ModelBackendNoAuthz(ModelBackend):
|
||||
def get_user_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def get_group_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def get_all_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def has_perm(self, user_obj, perm, obj=None):
|
||||
return False
|
||||
|
||||
def has_module_perms(self, user_obj, app_label):
|
||||
return False
|
||||
|
||||
def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
|
||||
return User.objects.none()
|
||||
|
||||
|
||||
class InbuiltBackend(ModelBackendNoAuthz):
|
||||
class InbuiltBackend(ModelBackend):
|
||||
"""Inbuilt backend"""
|
||||
|
||||
def authenticate(
|
||||
|
||||
@@ -8,7 +8,7 @@ from uuid import uuid4
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.translation import override
|
||||
@@ -47,7 +47,7 @@ async def aget_user(request):
|
||||
|
||||
|
||||
class AuthenticationMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request: HttpRequest) -> HttpResponseBadRequest | None:
|
||||
def process_request(self, request):
|
||||
if not hasattr(request, "session"):
|
||||
raise ImproperlyConfigured(
|
||||
"The Django authentication middleware requires session "
|
||||
@@ -62,8 +62,7 @@ class AuthenticationMiddleware(MiddlewareMixin):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and not user.is_active:
|
||||
logout(request)
|
||||
return HttpResponseBadRequest()
|
||||
return None
|
||||
raise AssertionError()
|
||||
|
||||
|
||||
class ImpersonateMiddleware:
|
||||
|
||||
@@ -6,6 +6,7 @@ import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import guardian.mixins
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -110,7 +111,7 @@ class Migration(migrations.Migration):
|
||||
options={
|
||||
"permissions": (("reset_user_password", "Reset Password"),),
|
||||
},
|
||||
bases=(models.Model,),
|
||||
bases=(guardian.mixins.GuardianUserMixin, models.Model),
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
# Generated by Django 5.1.12 on 2025-09-12 08:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
import psqlextra.backend.migrations.operations.apply_state
|
||||
import psqlextra.backend.migrations.operations.create_materialized_view_model
|
||||
import psqlextra.indexes.unique_index
|
||||
import psqlextra.manager.manager
|
||||
import psqlextra.models.view
|
||||
import uuid
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_parents(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Group = apps.get_model("authentik_core", "Group")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for group in Group.objects.using(db_alias).all():
|
||||
if not group.parent:
|
||||
continue
|
||||
group.parents.add(group.parent)
|
||||
group.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0054_alter_application_meta_icon_alter_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GroupParentageNode",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Group Parentage Node",
|
||||
"verbose_name_plural": "Group Parentage Nodes",
|
||||
"db_table": "authentik_core_groupparentage",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupparentagenode",
|
||||
name="child",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="parent_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupparentagenode",
|
||||
name="parent",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="child_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
psqlextra.backend.migrations.operations.create_materialized_view_model.PostgresCreateMaterializedViewModel(
|
||||
name="GroupAncestryNode",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_groupancestry",
|
||||
},
|
||||
view_options={
|
||||
"query": (
|
||||
"\n WITH RECURSIVE accumulator AS (\n SELECT\n child_id::text || '-' || parent_id::text as id,\n child_id AS descendant_id,\n parent_id AS ancestor_id\n FROM authentik_core_groupparentage\n\n UNION\n\n SELECT\n accumulator.descendant_id::text || '-' || current.parent_id::text as id,\n accumulator.descendant_id,\n current.parent_id AS ancestor_id\n FROM accumulator\n JOIN authentik_core_groupparentage current\n ON accumulator.ancestor_id = current.child_id\n )\n SELECT * FROM accumulator\n ",
|
||||
(),
|
||||
),
|
||||
},
|
||||
bases=(psqlextra.models.view.PostgresMaterializedViewModel,),
|
||||
managers=[
|
||||
("objects", psqlextra.manager.manager.PostgresManager()),
|
||||
],
|
||||
),
|
||||
psqlextra.backend.migrations.operations.apply_state.ApplyState(
|
||||
state_operation=migrations.AddField(
|
||||
model_name="groupancestrynode",
|
||||
name="ancestor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="descendant_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
),
|
||||
psqlextra.backend.migrations.operations.apply_state.ApplyState(
|
||||
state_operation=migrations.AddField(
|
||||
model_name="groupancestrynode",
|
||||
name="descendant",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="ancestor_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=models.Index(fields=["descendant"], name="authentik_c_descend_f83a71_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=models.Index(fields=["ancestor"], name="authentik_c_ancesto_974845_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=psqlextra.indexes.unique_index.UniqueIndex(
|
||||
fields=["id"], name="authentik_c_id_5d0bb4_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="groupparentagenode",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="refresh_groupancestry",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func="\n REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;\n RETURN NULL;\n ",
|
||||
hash="a987621714359aa0389e03fd2d52f86b118e7d24",
|
||||
operation="INSERT OR UPDATE OR DELETE",
|
||||
pgid="pgtrigger_refresh_groupancestry_62450",
|
||||
table="authentik_core_groupparentage",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="group",
|
||||
name="parents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="children",
|
||||
through="authentik_core.GroupParentageNode",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_parents, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,178 +0,0 @@
|
||||
# Generated by Django 5.1.12 on 2025-09-30 12:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
Role = apps.get_model("authentik_rbac", "Role")
|
||||
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
|
||||
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
|
||||
RoleObjectPermission = apps.get_model("guardian", "RoleObjectPermission")
|
||||
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
|
||||
|
||||
def get_role_for_user_id(user_id: int) -> Role:
|
||||
name = f"ak-migrated-role--user-{user_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
name=name,
|
||||
)
|
||||
if created:
|
||||
role.users.add(user_id)
|
||||
return role
|
||||
|
||||
def get_role_for_group_id(group_id: int) -> Role:
|
||||
role = Role.objects.using(db_alias).filter(group_id=group_id).first()
|
||||
if not role:
|
||||
# Every django group should already have a role, so this should never happen.
|
||||
# But let's be nice.
|
||||
name = f"ak-migrated-role--group-{group_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
)
|
||||
if created:
|
||||
role.group_id = group_id
|
||||
role.save()
|
||||
return role
|
||||
|
||||
# Below are 4 very similar pieces of code, for (user, group) x (model, object).
|
||||
# Since this is a one-off migration, I won't attempt DRYing them.
|
||||
|
||||
# User model permissions
|
||||
user_ids_with_model_permissions = (
|
||||
User.user_permissions.through.objects.using(db_alias)
|
||||
.values_list("user", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for user_id in user_ids_with_model_permissions:
|
||||
role = get_role_for_user_id(user_id)
|
||||
user_model_permissions = User.user_permissions.through.objects.using(db_alias).filter(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
role_model_permissions = []
|
||||
for user_model_permission in user_model_permissions:
|
||||
role_model_permissions.append(
|
||||
RoleModelPermission(
|
||||
permission=user_model_permission.permission,
|
||||
content_type=user_model_permission.permission.content_type,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
|
||||
|
||||
# Group model permissions
|
||||
group_ids_with_model_permissions = (
|
||||
Group.permissions.through.objects.using(db_alias).values_list("group", flat=True).distinct()
|
||||
)
|
||||
for group_id in group_ids_with_model_permissions:
|
||||
role = get_role_for_group_id(group_id)
|
||||
group_model_permissions = Group.permissions.through.objects.using(db_alias).filter(
|
||||
group_id=group_id
|
||||
)
|
||||
|
||||
role_model_permissions = []
|
||||
for group_model_permission in group_model_permissions:
|
||||
role_model_permissions.append(
|
||||
RoleModelPermission(
|
||||
permission=group_model_permission.permission,
|
||||
content_type=group_model_permission.permission.content_type,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
|
||||
|
||||
# User object permissions
|
||||
user_ids_with_object_permissions = (
|
||||
UserObjectPermission.objects.using(db_alias).values_list("user", flat=True).distinct()
|
||||
)
|
||||
for user_id in user_ids_with_object_permissions:
|
||||
role = get_role_for_user_id(user_id)
|
||||
user_object_permissions = UserObjectPermission.objects.using(db_alias).filter(user=user_id)
|
||||
|
||||
role_object_permissions = []
|
||||
for user_object_permission in user_object_permissions:
|
||||
role_object_permissions.append(
|
||||
RoleObjectPermission(
|
||||
permission=user_object_permission.permission,
|
||||
content_type=user_object_permission.content_type,
|
||||
object_pk=user_object_permission.object_pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
|
||||
|
||||
# Group object permissions
|
||||
group_ids_with_object_permissions = (
|
||||
GroupObjectPermission.objects.using(db_alias).values_list("group", flat=True).distinct()
|
||||
)
|
||||
for group_id in group_ids_with_object_permissions:
|
||||
role = get_role_for_group_id(group_id)
|
||||
group_object_permissions = GroupObjectPermission.objects.using(db_alias).filter(
|
||||
group=group_id
|
||||
)
|
||||
|
||||
role_object_permissions = []
|
||||
for group_object_permission in group_object_permissions:
|
||||
role_object_permissions.append(
|
||||
RoleObjectPermission(
|
||||
permission=group_object_permission.permission,
|
||||
content_type=group_object_permission.content_type,
|
||||
object_pk=group_object_permission.object_pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("guardian", "0004_role_permissions"),
|
||||
("authentik_core", "0055_groupancestor_groupparentagenode_group_parents"),
|
||||
("authentik_rbac", "0008_alter_role_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="roles",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="users", to="authentik_rbac.role"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_object_permissions),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="group",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="group",
|
||||
name="parents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="children",
|
||||
through="authentik_core.GroupParentageNode",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="group",
|
||||
name="parent",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="group",
|
||||
name="name",
|
||||
field=models.TextField(unique=True, verbose_name="name"),
|
||||
),
|
||||
]
|
||||
@@ -6,10 +6,9 @@ from hashlib import sha256
|
||||
from typing import Any, Optional, Self
|
||||
from uuid import uuid4
|
||||
|
||||
import pgtrigger
|
||||
from deepmerge import always_merger
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.models import AbstractUser, Permission
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.core.validators import validate_slug
|
||||
@@ -20,11 +19,10 @@ from django.http import HttpRequest
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_cte import CTE, with_cte
|
||||
from guardian.conf import settings
|
||||
from guardian.models import RoleModelPermission, RoleObjectPermission
|
||||
from guardian.mixins import GuardianUserMixin
|
||||
from model_utils.managers import InheritanceManager
|
||||
from psqlextra.indexes import UniqueIndex
|
||||
from psqlextra.models import PostgresMaterializedViewModel
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@@ -45,7 +43,6 @@ from authentik.lib.models import (
|
||||
)
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
|
||||
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
|
||||
|
||||
@@ -72,17 +69,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
|
||||
|
||||
GROUP_RECURSION_LIMIT = 20
|
||||
|
||||
MANAGED_ROLE_PREFIX_USER = "ak-managed-role--user"
|
||||
MANAGED_ROLE_PREFIX_GROUP = "ak-managed-role--group"
|
||||
|
||||
|
||||
def managed_role_name(user_or_group: models.Model):
|
||||
if isinstance(user_or_group, User):
|
||||
return f"{MANAGED_ROLE_PREFIX_USER}-{user_or_group.pk}"
|
||||
if isinstance(user_or_group, Group):
|
||||
return f"{MANAGED_ROLE_PREFIX_GROUP}-{user_or_group.pk}"
|
||||
raise TypeError("Managed roles are only available for User or Group.")
|
||||
|
||||
|
||||
def default_token_duration() -> datetime:
|
||||
"""Default duration a Token is valid"""
|
||||
@@ -152,7 +138,7 @@ class AttributesMixin(models.Model):
|
||||
@classmethod
|
||||
def update_or_create_attributes(
|
||||
cls, query: dict[str, Any], properties: dict[str, Any]
|
||||
) -> tuple[Self, bool]:
|
||||
) -> tuple[models.Model, bool]:
|
||||
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
|
||||
instance = cls.objects.filter(**query).first()
|
||||
if not instance:
|
||||
@@ -162,40 +148,69 @@ class AttributesMixin(models.Model):
|
||||
|
||||
|
||||
class GroupQuerySet(QuerySet):
|
||||
def with_descendants(self):
|
||||
pks = self.values_list("pk", flat=True)
|
||||
return Group.objects.filter(Q(pk__in=pks) | Q(ancestor_nodes__ancestor__in=pks)).distinct()
|
||||
def with_children_recursive(self):
|
||||
"""Recursively get all groups that have the current queryset as parents
|
||||
or are indirectly related."""
|
||||
|
||||
def with_ancestors(self):
|
||||
pks = self.values_list("pk", flat=True)
|
||||
return Group.objects.filter(
|
||||
Q(pk__in=pks) | Q(descendant_nodes__descendant__in=pks)
|
||||
).distinct()
|
||||
def make_cte(cte):
|
||||
"""Build the query that ends up in WITH RECURSIVE"""
|
||||
# Start from self, aka the current query
|
||||
# Add a depth attribute to limit the recursion
|
||||
return self.annotate(
|
||||
relative_depth=models.Value(0, output_field=models.IntegerField())
|
||||
).union(
|
||||
# Here is the recursive part of the query. cte refers to the previous iteration
|
||||
# Only select groups for which the parent is part of the previous iteration
|
||||
# and increase the depth
|
||||
# Finally, limit the depth
|
||||
cte.join(Group, group_uuid=cte.col.parent_id)
|
||||
.annotate(
|
||||
relative_depth=models.ExpressionWrapper(
|
||||
cte.col.relative_depth
|
||||
+ models.Value(1, output_field=models.IntegerField()),
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
)
|
||||
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
|
||||
all=True,
|
||||
)
|
||||
|
||||
# Build the recursive query, see above
|
||||
cte = CTE.recursive(make_cte)
|
||||
# Return the result, as a usable queryset for Group.
|
||||
return with_cte(cte, select=cte.join(Group, group_uuid=cte.col.group_uuid))
|
||||
|
||||
|
||||
class Group(SerializerModel, AttributesMixin):
|
||||
"""Group model which supports a hierarchy and has attributes"""
|
||||
"""Group model which supports a basic hierarchy and has attributes"""
|
||||
|
||||
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField(verbose_name=_("name"), unique=True)
|
||||
name = models.TextField(_("name"))
|
||||
is_superuser = models.BooleanField(
|
||||
default=False, help_text=_("Users added to this group will be superusers.")
|
||||
)
|
||||
|
||||
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
|
||||
|
||||
parents = models.ManyToManyField(
|
||||
parent = models.ForeignKey(
|
||||
"Group",
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
through="GroupParentageNode",
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="children",
|
||||
)
|
||||
|
||||
objects = GroupQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
(
|
||||
"name",
|
||||
"parent",
|
||||
),
|
||||
)
|
||||
indexes = (
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["is_superuser"]),
|
||||
@@ -229,103 +244,12 @@ class Group(SerializerModel, AttributesMixin):
|
||||
"""Recursively check if `user` is member of us, or any parent."""
|
||||
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
|
||||
|
||||
def all_roles(self) -> QuerySet[Role]:
|
||||
"""Get all roles of this group and all of its ancestors."""
|
||||
return Role.objects.filter(
|
||||
ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
|
||||
).distinct()
|
||||
|
||||
def get_managed_role(self, create=False):
|
||||
if create:
|
||||
name = managed_role_name(self)
|
||||
role, created = Role.objects.get_or_create(name=name, managed=name)
|
||||
if created:
|
||||
role.ak_groups.add(self)
|
||||
return role
|
||||
else:
|
||||
return Role.objects.filter(name=managed_role_name(self)).first()
|
||||
|
||||
def assign_perms_to_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
if not perms:
|
||||
return
|
||||
role = self.get_managed_role(create=True)
|
||||
role.assign_perms(perms, obj)
|
||||
|
||||
|
||||
class GroupParentageNode(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
child = models.ForeignKey(Group, related_name="parent_nodes", on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey(Group, related_name="child_nodes", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Group Parentage Node")
|
||||
verbose_name_plural = _("Group Parentage Nodes")
|
||||
|
||||
db_table = "authentik_core_groupparentage"
|
||||
|
||||
triggers = [
|
||||
pgtrigger.Trigger(
|
||||
name="refresh_groupancestry",
|
||||
operation=pgtrigger.Insert | pgtrigger.Update | pgtrigger.Delete,
|
||||
when=pgtrigger.After,
|
||||
func="""
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;
|
||||
RETURN NULL;
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Group Parentage Node from #{self.child_id} to {self.parent_id}"
|
||||
|
||||
|
||||
class GroupAncestryNode(PostgresMaterializedViewModel):
|
||||
descendant = models.ForeignKey(
|
||||
Group, related_name="ancestor_nodes", on_delete=models.DO_NOTHING
|
||||
)
|
||||
ancestor = models.ForeignKey(
|
||||
Group, related_name="descendant_nodes", on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
class Meta:
|
||||
# This is a transitive closure of authentik_core_groupparentage
|
||||
# See https://en.wikipedia.org/wiki/Transitive_closure#In_graph_theory
|
||||
db_table = "authentik_core_groupancestry"
|
||||
indexes = [
|
||||
models.Index(fields=["descendant"]),
|
||||
models.Index(fields=["ancestor"]),
|
||||
UniqueIndex(fields=["id"]),
|
||||
]
|
||||
|
||||
class ViewMeta:
|
||||
query = """
|
||||
WITH RECURSIVE accumulator AS (
|
||||
SELECT
|
||||
child_id::text || '-' || parent_id::text as id,
|
||||
child_id AS descendant_id,
|
||||
parent_id AS ancestor_id
|
||||
FROM authentik_core_groupparentage
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
accumulator.descendant_id::text || '-' || current.parent_id::text as id,
|
||||
accumulator.descendant_id,
|
||||
current.parent_id AS ancestor_id
|
||||
FROM accumulator
|
||||
JOIN authentik_core_groupparentage current
|
||||
ON accumulator.ancestor_id = current.child_id
|
||||
)
|
||||
SELECT * FROM accumulator
|
||||
"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Group Ancestry Node from {self.descendant_id} to {self.ancestor_id}"
|
||||
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
|
||||
"""Compatibility layer for Group.objects.with_children_recursive()"""
|
||||
qs = self
|
||||
if not isinstance(self, QuerySet):
|
||||
qs = Group.objects.filter(group_uuid=self.group_uuid)
|
||||
return qs.with_children_recursive()
|
||||
|
||||
|
||||
class UserQuerySet(models.QuerySet):
|
||||
@@ -352,7 +276,7 @@ class UserManager(DjangoUserManager):
|
||||
return self.get_queryset().exclude_anonymous()
|
||||
|
||||
|
||||
class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
"""authentik User model, based on django's contrib auth user model."""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
||||
@@ -362,7 +286,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
|
||||
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True)
|
||||
password_change_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
@@ -400,60 +323,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
|
||||
def all_groups(self) -> QuerySet[Group]:
|
||||
"""Recursively get all groups this user is a member of."""
|
||||
return self.ak_groups.all().with_ancestors()
|
||||
|
||||
def all_roles(self) -> QuerySet[Role]:
|
||||
"""Get all roles of this user and all of its groups (recursively)."""
|
||||
return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct()
|
||||
|
||||
def get_managed_role(self, create=False):
|
||||
if create:
|
||||
name = managed_role_name(self)
|
||||
role, created = Role.objects.get_or_create(name=name, managed=name)
|
||||
if created:
|
||||
role.users.add(self)
|
||||
return role
|
||||
else:
|
||||
return Role.objects.filter(name=managed_role_name(self)).first()
|
||||
|
||||
def get_all_model_perms_on_managed_role(self) -> QuerySet[RoleModelPermission]:
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return RoleModelPermission.objects.none()
|
||||
return RoleModelPermission.objects.filter(role=role)
|
||||
|
||||
def get_all_obj_perms_on_managed_role(self) -> QuerySet[RoleObjectPermission]:
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return RoleObjectPermission.objects.none()
|
||||
return RoleObjectPermission.objects.filter(role=role)
|
||||
|
||||
def assign_perms_to_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
if not perms:
|
||||
return
|
||||
role = self.get_managed_role(create=True)
|
||||
role.assign_perms(perms, obj)
|
||||
|
||||
def remove_perms_from_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return None
|
||||
role.remove_perms(perms, obj)
|
||||
|
||||
def remove_all_perms_from_managed_role(self):
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return None
|
||||
RoleModelPermission.objects.filter(role=role).delete()
|
||||
RoleObjectPermission.objects.filter(role=role).delete()
|
||||
return self.ak_groups.all().with_children_recursive()
|
||||
|
||||
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
|
||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||
@@ -658,10 +528,6 @@ class ApplicationQuerySet(QuerySet):
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
# Also prefetch/select through each subclass path to ensure casted instances have access
|
||||
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
|
||||
qs = qs.select_related(f"provider__{subclass}__application")
|
||||
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
|
||||
return qs
|
||||
|
||||
|
||||
@@ -713,21 +579,8 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
|
||||
|
||||
@property
|
||||
def get_meta_icon_themed_urls(self) -> dict[str, str] | None:
|
||||
"""Get themed URLs for meta_icon if it contains %(theme)s"""
|
||||
if not self.meta_icon:
|
||||
return None
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.meta_icon)
|
||||
|
||||
def get_launch_url(self, user: User | None = None, user_data: dict | None = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
|
||||
|
||||
Args:
|
||||
user: User instance for formatting the URL
|
||||
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
|
||||
"""
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
@@ -737,10 +590,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
try:
|
||||
# Use pre-serialized data if available, otherwise serialize now
|
||||
if user_data is None:
|
||||
user_data = UserSerializer(instance=user).data
|
||||
return url % user_data
|
||||
return url % UserSerializer(instance=user).data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
@@ -935,14 +785,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
|
||||
|
||||
@property
|
||||
def icon_themed_urls(self) -> dict[str, str] | None:
|
||||
"""Get themed URLs for icon if it contains %(theme)s"""
|
||||
if not self.icon:
|
||||
return None
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).themed_urls(self.icon)
|
||||
|
||||
def get_user_path(self) -> str:
|
||||
"""Get user path, fallback to default for formatting errors"""
|
||||
try:
|
||||
|
||||
@@ -34,12 +34,19 @@ class SessionStore(SessionBase):
|
||||
|
||||
def _get_session_from_db(self):
|
||||
try:
|
||||
return self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
"authenticatedsession__user__user_permissions",
|
||||
)
|
||||
.get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
@@ -48,12 +55,19 @@ class SessionStore(SessionBase):
|
||||
|
||||
async def _aget_session_from_db(self):
|
||||
try:
|
||||
return await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
"authenticatedsession__user__user_permissions",
|
||||
)
|
||||
.aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
@@ -66,12 +80,9 @@ class SessionStore(SessionBase):
|
||||
def decode(self, session_data):
|
||||
try:
|
||||
return pickle.loads(session_data) # nosec
|
||||
except (pickle.PickleError, AttributeError, TypeError):
|
||||
# PickleError, ValueError - unpickling exceptions
|
||||
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
|
||||
# and their descriptors fail to initialize (e.g., missing storage)
|
||||
# TypeError - can happen with incompatible pickled objects
|
||||
# If any of these happen, just return an empty dictionary (an empty session)
|
||||
except pickle.PickleError:
|
||||
# ValueError, unpickling exceptions. If any of these happen, just return an empty
|
||||
# dictionary (an empty session)
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest,
|
||||
# stage: Stage, context: dict[str, any]
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||
login_failed = Signal()
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -35,13 +35,8 @@ def clean_expired_models():
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
clear_expired_cache()
|
||||
for cls in [Message, GroupChannel]:
|
||||
objects = cls.objects.all().filter(expires__lt=now())
|
||||
amount = objects.count()
|
||||
for obj in chunked_queryset(objects):
|
||||
obj.delete()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
Message.delete_expired()
|
||||
GroupChannel.delete_expired()
|
||||
|
||||
|
||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
||||
|
||||
@@ -10,23 +10,15 @@
|
||||
{% elif ui_theme == "light" %}
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% else %}
|
||||
{% else %}
|
||||
<script data-id="theme-script">
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
try {
|
||||
/* Ignore older theme names */
|
||||
let locallyStoredTheme = window.localStorage?.getItem("theme") || null;
|
||||
if (typeof locallyStoredTheme === "string") {
|
||||
locallyStoredTheme = locallyStoredTheme.trim();
|
||||
}
|
||||
if (!(["auto", "light", "dark"].includes(locallyStoredTheme))) {
|
||||
locallyStoredTheme = null;
|
||||
}
|
||||
|
||||
const initialThemeChoice =
|
||||
new URLSearchParams(window.location.search).get("theme") || locallyStoredTheme;
|
||||
new URLSearchParams(window.location.search).get("theme") ||
|
||||
window.localStorage?.getItem("theme");
|
||||
|
||||
const themeChoice =
|
||||
initialThemeChoice || document.documentElement.dataset.themeChoice || "auto";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test Application Entitlements API"""
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application, ApplicationEntitlement, Group
|
||||
@@ -48,8 +49,7 @@ class TestApplicationEntitlements(APITestCase):
|
||||
def test_group_indirect(self):
|
||||
"""Test indirect group"""
|
||||
parent = Group.objects.create(name=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.parents.add(parent)
|
||||
group = Group.objects.create(name=generate_id(), parent=parent)
|
||||
self.user.ak_groups.add(group)
|
||||
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
|
||||
PolicyBinding.objects.create(target=ent, group=parent, order=0)
|
||||
@@ -76,8 +76,8 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_global(self):
|
||||
"""Test API creation with global permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_application")
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
assign_perm("authentik_core.view_application", self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
@@ -90,8 +90,8 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_scoped(self):
|
||||
"""Test API creation with scoped permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_application", self.app)
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
assign_perm("authentik_core.view_application", self.user, self.app)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
@@ -104,7 +104,7 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_missing(self):
|
||||
"""Test API creation with no permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
|
||||
@@ -107,8 +107,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
@@ -127,7 +125,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -165,8 +162,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
@@ -185,7 +180,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -195,7 +189,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"meta_description": "",
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
"meta_launch_url": "",
|
||||
"open_in_new_tab": False,
|
||||
"meta_publisher": "",
|
||||
|
||||
@@ -25,8 +25,7 @@ class TestGroups(TestCase):
|
||||
user = User.objects.create(username=generate_id())
|
||||
user2 = User.objects.create(username=generate_id())
|
||||
parent = Group.objects.create(name=generate_id())
|
||||
child = Group.objects.create(name=generate_id())
|
||||
child.parents.add(parent)
|
||||
child = Group.objects.create(name=generate_id(), parent=parent)
|
||||
child.users.add(user)
|
||||
self.assertTrue(child.is_member(user))
|
||||
self.assertTrue(parent.is_member(user))
|
||||
@@ -38,10 +37,8 @@ class TestGroups(TestCase):
|
||||
user = User.objects.create(username=generate_id())
|
||||
user2 = User.objects.create(username=generate_id())
|
||||
parent = Group.objects.create(name=generate_id())
|
||||
second = Group.objects.create(name=generate_id())
|
||||
second.parents.add(parent)
|
||||
third = Group.objects.create(name=generate_id())
|
||||
third.parents.add(second)
|
||||
second = Group.objects.create(name=generate_id(), parent=parent)
|
||||
third = Group.objects.create(name=generate_id(), parent=second)
|
||||
second.users.add(user)
|
||||
self.assertTrue(parent.is_member(user))
|
||||
self.assertFalse(parent.is_member(user2))
|
||||
@@ -54,21 +51,9 @@ class TestGroups(TestCase):
|
||||
"""Test group membership (recursive)"""
|
||||
user = User.objects.create(username=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group2 = Group.objects.create(name=generate_id())
|
||||
group.parents.add(group2)
|
||||
group2.parents.add(group)
|
||||
group2 = Group.objects.create(name=generate_id(), parent=group)
|
||||
group.users.add(user)
|
||||
group.parent = group2
|
||||
group.save()
|
||||
self.assertTrue(group.is_member(user))
|
||||
self.assertTrue(group2.is_member(user))
|
||||
|
||||
def test_group_managed_role(self):
|
||||
"""Test group managed role"""
|
||||
perm = "authentik_core.view_user"
|
||||
user = User.objects.create(username=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(user)
|
||||
group.assign_perms_to_managed_role(perm)
|
||||
self.assertEqual(group.roles.count(), 1)
|
||||
self.assertEqual(user.roles.count(), 0)
|
||||
self.assertTrue(user.has_perm(perm))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test Groups API"""
|
||||
|
||||
from django.urls.base import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Group
|
||||
@@ -36,8 +37,8 @@ class TestGroupsAPI(APITestCase):
|
||||
def test_add_user(self):
|
||||
"""Test add_user"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
|
||||
assign_perm("authentik_core.view_user", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
|
||||
@@ -52,8 +53,8 @@ class TestGroupsAPI(APITestCase):
|
||||
def test_add_user_404(self):
|
||||
"""Test add_user"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
|
||||
assign_perm("authentik_core.view_user", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
|
||||
@@ -66,8 +67,8 @@ class TestGroupsAPI(APITestCase):
|
||||
def test_remove_user(self):
|
||||
"""Test remove_user"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
|
||||
assign_perm("authentik_core.view_user", self.login_user)
|
||||
group.users.add(self.user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
@@ -83,8 +84,8 @@ class TestGroupsAPI(APITestCase):
|
||||
def test_remove_user_404(self):
|
||||
"""Test remove_user"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
|
||||
assign_perm("authentik_core.view_user", self.login_user)
|
||||
group.users.add(self.user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
@@ -95,9 +96,23 @@ class TestGroupsAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
|
||||
def test_parent_self(self):
|
||||
"""Test parent"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={
|
||||
"parent": group.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_superuser_no_perm(self):
|
||||
"""Test creating a superuser group without permission"""
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
@@ -111,7 +126,7 @@ class TestGroupsAPI(APITestCase):
|
||||
|
||||
def test_superuser_no_perm_no_superuser(self):
|
||||
"""Test creating a group without permission and without superuser flag"""
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
@@ -122,8 +137,8 @@ class TestGroupsAPI(APITestCase):
|
||||
def test_superuser_update_no_perm(self):
|
||||
"""Test updating a superuser group without permission"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
self.login_user.assign_perms_to_managed_role("view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("change_group", group)
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
@@ -139,8 +154,8 @@ class TestGroupsAPI(APITestCase):
|
||||
"""Test updating a superuser group without permission
|
||||
and without changing the superuser status"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
self.login_user.assign_perms_to_managed_role("view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("change_group", group)
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
@@ -150,8 +165,8 @@ class TestGroupsAPI(APITestCase):
|
||||
|
||||
def test_superuser_create(self):
|
||||
"""Test creating a superuser group with permission"""
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.enable_group_superuser")
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
assign_perm("authentik_core.enable_group_superuser", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
@@ -47,8 +48,8 @@ class TestImpersonation(APITestCase):
|
||||
def test_impersonate_global(self):
|
||||
"""Test impersonation with global permissions"""
|
||||
new_user = create_test_user()
|
||||
new_user.assign_perms_to_managed_role("authentik_core.impersonate")
|
||||
new_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
assign_perm("authentik_core.impersonate", new_user)
|
||||
assign_perm("authentik_core.view_user", new_user)
|
||||
self.client.force_login(new_user)
|
||||
|
||||
response = self.client.post(
|
||||
@@ -68,8 +69,8 @@ class TestImpersonation(APITestCase):
|
||||
def test_impersonate_scoped(self):
|
||||
"""Test impersonation with scoped permissions"""
|
||||
new_user = create_test_user()
|
||||
new_user.assign_perms_to_managed_role("authentik_core.impersonate", self.other_user)
|
||||
new_user.assign_perms_to_managed_role("authentik_core.view_user", self.other_user)
|
||||
assign_perm("authentik_core.impersonate", new_user, self.other_user)
|
||||
assign_perm("authentik_core.view_user", new_user, self.other_user)
|
||||
self.client.force_login(new_user)
|
||||
|
||||
response = self.client.post(
|
||||
|
||||
@@ -39,7 +39,7 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
|
||||
def tester(self: TestModels):
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
return
|
||||
model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
model_class.slug = "test"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import SourceUserMatchingModes, User
|
||||
from authentik.core.sources.flow_manager import Action
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test Transactional API"""
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application, Group
|
||||
@@ -15,8 +16,8 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_user()
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_application")
|
||||
self.user.assign_perms_to_managed_role("authentik_providers_oauth2.add_oauth2provider")
|
||||
assign_perm("authentik_core.add_application", self.user)
|
||||
assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user)
|
||||
|
||||
def test_create_transactional(self):
|
||||
"""Test transactional Application + provider creation"""
|
||||
@@ -72,7 +73,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
|
||||
|
||||
def test_create_transactional_bindings(self):
|
||||
"""Test transactional Application + provider creation"""
|
||||
self.user.assign_perms_to_managed_role("authentik_policies.add_policybinding")
|
||||
assign_perm("authentik_policies.add_policybinding", self.user)
|
||||
self.client.force_login(self.user)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(name=generate_id())
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""user tests"""
|
||||
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestUsers(TestCase):
|
||||
"""Test user"""
|
||||
|
||||
def test_user_managed_role(self):
|
||||
"""Test user managed role"""
|
||||
perm = "authentik_core.view_user"
|
||||
user = User.objects.create(username=generate_id())
|
||||
user.assign_perms_to_managed_role(perm)
|
||||
self.assertEqual(user.roles.count(), 1)
|
||||
self.assertTrue(user.has_perm(perm))
|
||||
user.remove_perms_from_managed_role(perm)
|
||||
self.assertFalse(user.has_perm(perm))
|
||||
@@ -9,6 +9,7 @@ from cryptography.x509.extensions import SubjectAlternativeName
|
||||
from cryptography.x509.general_name import DNSName
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.api.used_by import DeleteAction
|
||||
@@ -193,8 +194,8 @@ class TestCrypto(APITestCase):
|
||||
"""Test certificate export (download)"""
|
||||
keypair = create_test_cert()
|
||||
user = create_test_user()
|
||||
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
|
||||
user.assign_perms_to_managed_role("view_certificatekeypair_certificate", keypair)
|
||||
assign_perm("view_certificatekeypair", user, keypair)
|
||||
assign_perm("view_certificatekeypair_certificate", user, keypair)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
@@ -217,8 +218,8 @@ class TestCrypto(APITestCase):
|
||||
"""Test private_key export (download)"""
|
||||
keypair = create_test_cert()
|
||||
user = create_test_user()
|
||||
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
|
||||
user.assign_perms_to_managed_role("view_certificatekeypair_key", keypair)
|
||||
assign_perm("view_certificatekeypair", user, keypair)
|
||||
assign_perm("view_certificatekeypair_key", user, keypair)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
|
||||
@@ -20,7 +20,6 @@ class DeviceUserBindingSerializer(PolicyBindingSerializer):
|
||||
class DeviceUserBindingViewSet(PolicyBindingViewSet):
|
||||
"""PolicyBinding Viewset"""
|
||||
|
||||
serializer_class = DeviceUserBindingSerializer
|
||||
queryset = (
|
||||
DeviceUserBinding.objects.all()
|
||||
.select_related("target", "group", "user")
|
||||
|
||||
@@ -3,10 +3,11 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.endpoints.api.connectors import ConnectorSerializer
|
||||
from authentik.endpoints.models import EndpointStage
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
|
||||
|
||||
class EndpointStageSerializer(StageSerializer):
|
||||
class EndpointStageSerializer(EnterpriseRequiredMixin, StageSerializer):
|
||||
"""EndpointStage Serializer"""
|
||||
|
||||
connector_obj = ConnectorSerializer(source="connector", read_only=True)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.db import models
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
@@ -15,12 +14,6 @@ from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
|
||||
try:
|
||||
from authentik.enterprise.models import LicenseUsageStatus
|
||||
except ImportError:
|
||||
|
||||
class LicenseUsageStatus(models.TextChoices): ...
|
||||
|
||||
|
||||
class AgentConfigSerializer(PassiveSerializer):
|
||||
|
||||
@@ -36,7 +29,6 @@ class AgentConfigSerializer(PassiveSerializer):
|
||||
auth_terminate_session_on_expiry = BooleanField()
|
||||
|
||||
system_config = SerializerMethodField()
|
||||
license_status = SerializerMethodField(required=False, allow_null=True)
|
||||
|
||||
def get_device_id(self, instance: AgentConnector) -> str:
|
||||
device: Device = self.context["device"]
|
||||
@@ -62,14 +54,6 @@ class AgentConfigSerializer(PassiveSerializer):
|
||||
def get_system_config(self, instance: AgentConnector) -> ConfigSerializer:
|
||||
return ConfigView.get_config(self.context["request"]).data
|
||||
|
||||
def get_license_status(self, instance: AgentConnector) -> "LicenseUsageStatus":
|
||||
try:
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
|
||||
return LicenseKey.cached_summary().status
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
class EnrollSerializer(PassiveSerializer):
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import cast
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
ChoiceField,
|
||||
)
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@@ -24,11 +25,7 @@ from authentik.endpoints.connectors.agent.api.agent import (
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
AgentAuth,
|
||||
AgentEnrollmentAuth,
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
@@ -37,10 +34,7 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
)
|
||||
from authentik.endpoints.facts import DeviceFacts, OSFamily
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class AgentConnectorSerializer(ConnectorSerializer):
|
||||
@@ -80,6 +74,11 @@ class MDMConfigSerializer(PassiveSerializer):
|
||||
return token
|
||||
|
||||
|
||||
class MDMConfigResponseSerializer(PassiveSerializer):
|
||||
|
||||
config = CharField(required=True)
|
||||
|
||||
|
||||
class AgentConnectorViewSet(
|
||||
ConditionalInheritance(
|
||||
"authentik.enterprise.endpoints.connectors.agent.api.connectors.AgentConnectorViewSetMixin"
|
||||
@@ -109,7 +108,7 @@ class AgentConnectorViewSet(
|
||||
raise PermissionDenied()
|
||||
ctrl = connector.controller(connector)
|
||||
payload = ctrl.generate_mdm_config(data.validated_data["platform"], request, token)
|
||||
return Response(payload.validated_data)
|
||||
return Response({"config": payload})
|
||||
|
||||
@extend_schema(
|
||||
request=EnrollSerializer(),
|
||||
@@ -171,43 +170,3 @@ class AgentConnectorViewSet(
|
||||
connection: AgentDeviceConnection = token.device
|
||||
connection.create_snapshot(data.validated_data)
|
||||
return Response(status=204)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.tokens import TokenViewSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
@@ -21,11 +19,6 @@ class EnrollmentTokenSerializer(ModelSerializer):
|
||||
source="device_group", read_only=True, required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = EnrollmentToken
|
||||
fields = [
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import IPCUser, validate_auth
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
|
||||
|
||||
|
||||
class DeviceUser(IPCUser):
|
||||
@@ -55,96 +40,3 @@ class AgentAuth(BaseAuthentication):
|
||||
raise PermissionDenied()
|
||||
CTX_AUTH_VIA.set("endpoint_token")
|
||||
return (DeviceUser(), device_token)
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
|
||||
@@ -4,9 +4,7 @@ from xml.etree.ElementTree import Element, SubElement, tostring # nosec
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from rest_framework.fields import CharField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
|
||||
from authentik.endpoints.controller import BaseController
|
||||
from authentik.endpoints.facts import OSFamily
|
||||
@@ -35,13 +33,6 @@ def csp_create_replace_item(loc_uri, data_value) -> Element:
|
||||
return replace
|
||||
|
||||
|
||||
class MDMConfigResponseSerializer(PassiveSerializer):
|
||||
|
||||
config = CharField(required=True)
|
||||
mime_type = CharField(required=True)
|
||||
filename = CharField(required=True)
|
||||
|
||||
|
||||
class AgentConnectorController(BaseController[AgentConnector]):
|
||||
|
||||
def supported_enrollment_methods(self):
|
||||
@@ -49,20 +40,14 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
|
||||
def generate_mdm_config(
|
||||
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken
|
||||
) -> MDMConfigResponseSerializer:
|
||||
response = None
|
||||
) -> str:
|
||||
if target_platform == OSFamily.windows:
|
||||
response = self._generate_mdm_config_windows(request, token)
|
||||
return self._generate_mdm_config_windows(request, token)
|
||||
if target_platform in [OSFamily.iOS, OSFamily.macOS]:
|
||||
response = self._generate_mdm_config_macos(request, token)
|
||||
if not response:
|
||||
raise ValueError(f"Unsupported platform for MDM Configuration: {target_platform}")
|
||||
response.is_valid(raise_exception=True)
|
||||
return response
|
||||
return self._generate_mdm_config_macos(request, token)
|
||||
raise ValueError(f"Unsupported platform for MDM Configuration: {target_platform}")
|
||||
|
||||
def _generate_mdm_config_windows(
|
||||
self, request: HttpRequest, token: EnrollmentToken
|
||||
) -> MDMConfigResponseSerializer:
|
||||
def _generate_mdm_config_windows(self, request: HttpRequest, token: EnrollmentToken) -> str:
|
||||
base_uri = (
|
||||
"./Vendor/MSFT/Registry/HKLM/SOFTWARE/authentik Security Inc./Platform/ManagedConfig"
|
||||
)
|
||||
@@ -76,17 +61,9 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
)
|
||||
|
||||
payload = tostring(token_item, encoding="unicode") + tostring(url_item, encoding="unicode")
|
||||
return MDMConfigResponseSerializer(
|
||||
data={
|
||||
"config": payload,
|
||||
"mime_type": "application/xml",
|
||||
"filename": f"{self.connector.name}_config.csp.xml",
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
def _generate_mdm_config_macos(
|
||||
self, request: HttpRequest, token: EnrollmentToken
|
||||
) -> MDMConfigResponseSerializer:
|
||||
def _generate_mdm_config_macos(self, request: HttpRequest, token: EnrollmentToken) -> str:
|
||||
token_uuid = str(token.pk).upper()
|
||||
payload = dumps(
|
||||
{
|
||||
@@ -153,10 +130,4 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
},
|
||||
fmt=PlistFormat.FMT_XML,
|
||||
).decode()
|
||||
return MDMConfigResponseSerializer(
|
||||
data={
|
||||
"config": payload,
|
||||
"mime_type": "application/xml",
|
||||
"filename": f"{self.connector.name}_config.mobileconfig",
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
|
||||
from django.http import HttpResponse
|
||||
@@ -9,7 +8,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import DeviceAuthenticationToken, DeviceToken
|
||||
from authentik.endpoints.connectors.agent.models import DeviceToken
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@@ -21,7 +20,6 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms
|
||||
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN = "goauthentik.io/endpoints/device_auth_token" # nosec
|
||||
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
|
||||
QS_CHALLENGE = "challenge"
|
||||
QS_CHALLENGE_RESPONSE = "response"
|
||||
@@ -87,36 +85,12 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
|
||||
response_class = EndpointAgentChallengeResponse
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Check if we're in a device interactive auth flow, in which case we use that
|
||||
# to prove which device is being used
|
||||
if response := self.check_device_ia():
|
||||
return response
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
|
||||
if not keypair:
|
||||
return self.executor.stage_ok()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def check_device_ia(self):
|
||||
"""Check if we're in a device interactive authentication flow, and if so,
|
||||
there won't be a browser extension to talk to. However we can authenticate
|
||||
on the DTH header"""
|
||||
if PLAN_CONTEXT_DEVICE_AUTH_TOKEN not in self.executor.plan.context:
|
||||
return None
|
||||
auth_token: DeviceAuthenticationToken = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN
|
||||
)
|
||||
device_token_hash = self.request.headers.get("X-Authentik-Platform-Auth-DTH")
|
||||
if not device_token_hash:
|
||||
return None
|
||||
if not compare_digest(
|
||||
device_token_hash, sha256(auth_token.device_token.key.encode()).hexdigest()
|
||||
):
|
||||
return self.executor.stage_invalid("Invalid device token")
|
||||
self.logger.debug("Setting device based on DTH header")
|
||||
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = auth_token.device
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
|
||||
|
||||
@@ -23,8 +23,8 @@ class TestAgentConnector(APITestCase):
|
||||
res = self.connector.controller(self.connector).generate_mdm_config(
|
||||
OSFamily.macOS, request, self.token
|
||||
)
|
||||
self.assertIsNotNone(res.validated_data)
|
||||
data = loads(res.validated_data["config"], fmt=PlistFormat.FMT_XML)
|
||||
self.assertIsNotNone(res)
|
||||
data = loads(res, fmt=PlistFormat.FMT_XML)
|
||||
self.assertEqual(data["PayloadContent"][0]["RegistrationToken"], self.token.key)
|
||||
self.assertEqual(data["PayloadContent"][0]["URL"], "http://testserver/")
|
||||
|
||||
@@ -33,8 +33,7 @@ class TestAgentConnector(APITestCase):
|
||||
res = self.connector.controller(self.connector).generate_mdm_config(
|
||||
OSFamily.windows, request, self.token
|
||||
)
|
||||
self.assertIsNotNone(res.validated_data)
|
||||
config = res.validated_data["config"]
|
||||
fromstring(f"<root>{config}</root>")
|
||||
self.assertIn(self.token.key, config)
|
||||
self.assertIn("http://testserver/", config)
|
||||
self.assertIsNotNone(res)
|
||||
fromstring(f"<root>{res}</root>")
|
||||
self.assertIn(self.token.key, res)
|
||||
self.assertIn("http://testserver/", res)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from hashlib import sha256
|
||||
from json import loads
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from jwt import encode
|
||||
@@ -9,14 +7,10 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
DeviceAuthenticationToken,
|
||||
DeviceToken,
|
||||
EnrollmentToken,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.stage import (
|
||||
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE,
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.stage import PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
@@ -41,11 +35,6 @@ class TestEndpointStage(FlowTestCase):
|
||||
device=self.connection,
|
||||
key=generate_id(),
|
||||
)
|
||||
self.device_auth_token = DeviceAuthenticationToken.objects.create(
|
||||
device=self.device,
|
||||
device_token=self.device_token,
|
||||
connector=self.connector,
|
||||
)
|
||||
|
||||
def test_endpoint_stage(self):
|
||||
flow = create_test_flow()
|
||||
@@ -205,71 +194,3 @@ class TestEndpointStage(FlowTestCase):
|
||||
"response": [{"string": "Invalid challenge response", "code": "invalid"}]
|
||||
},
|
||||
)
|
||||
|
||||
def test_endpoint_stage_ia_dth(self):
|
||||
"""Test with DTH"""
|
||||
flow = create_test_flow()
|
||||
stage = EndpointStage.objects.create(connector=self.connector)
|
||||
FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
|
||||
|
||||
# Send an "invalid" request first, to populate the flow plan
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
plan = self.get_flow_plan()
|
||||
plan.context[PLAN_CONTEXT_DEVICE_AUTH_TOKEN] = DeviceAuthenticationToken.objects.get(
|
||||
pk=self.device_auth_token.pk
|
||||
)
|
||||
self.set_flow_plan(plan)
|
||||
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
HTTP_X_AUTHENTIK_PLATFORM_AUTH_DTH=sha256(
|
||||
self.device_token.key.encode()
|
||||
).hexdigest(),
|
||||
)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
plan = plan()
|
||||
self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
|
||||
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], self.device)
|
||||
|
||||
def test_endpoint_stage_connector_no_stage_optional(self):
|
||||
flow = create_test_flow()
|
||||
stage = EndpointStage.objects.create(connector=self.connector, mode=StageMode.OPTIONAL)
|
||||
FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
|
||||
|
||||
with patch(
|
||||
"authentik.endpoints.connectors.agent.models.AgentConnector.stage",
|
||||
PropertyMock(return_value=None),
|
||||
):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
plan = plan()
|
||||
self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
|
||||
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
|
||||
|
||||
def test_endpoint_stage_connector_no_stage_required(self):
|
||||
flow = create_test_flow()
|
||||
stage = EndpointStage.objects.create(connector=self.connector, mode=StageMode.REQUIRED)
|
||||
FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
|
||||
|
||||
with patch(
|
||||
"authentik.endpoints.connectors.agent.models.AgentConnector.stage",
|
||||
PropertyMock(return_value=None),
|
||||
):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
res,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="Invalid stage configuration",
|
||||
)
|
||||
plan = plan()
|
||||
self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
|
||||
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
|
||||
|
||||
@@ -175,7 +175,7 @@ class Connector(ScheduledModel, SerializerModel):
|
||||
]
|
||||
|
||||
|
||||
class DeviceAccessGroup(SerializerModel, PolicyBindingModel):
|
||||
class DeviceAccessGroup(PolicyBindingModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from authentik.endpoints.models import EndpointStage, StageMode
|
||||
from authentik.endpoints.models import EndpointStage
|
||||
from authentik.flows.stage import StageView
|
||||
|
||||
PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
|
||||
@@ -6,24 +6,15 @@ PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
|
||||
|
||||
class EndpointStageView(StageView):
|
||||
|
||||
def _get_inner(self) -> StageView | None:
|
||||
def _get_inner(self):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
inner_stage: type[StageView] | None = stage.connector.stage
|
||||
if not inner_stage:
|
||||
return None
|
||||
return self.executor.stage_ok()
|
||||
return inner_stage(self.executor, request=self.request)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
inner = self._get_inner()
|
||||
if inner is None:
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
if stage.mode == StageMode.OPTIONAL:
|
||||
return self.executor.stage_ok()
|
||||
else:
|
||||
return self.executor.stage_invalid("Invalid stage configuration")
|
||||
return inner.dispatch(request, *args, **kwargs)
|
||||
return self._get_inner().dispatch(request, *args, **kwargs)
|
||||
|
||||
def cleanup(self):
|
||||
inner = self._get_inner()
|
||||
if inner is not None:
|
||||
return inner.cleanup()
|
||||
return self._get_inner().cleanup()
|
||||
|
||||
@@ -60,18 +60,20 @@ class TestEndpointFacts(APITestCase):
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertCountEqual(
|
||||
device.cached_facts.data["software"],
|
||||
[
|
||||
{
|
||||
"name": "software-a",
|
||||
"version": "1.2.3.4",
|
||||
"source": "package",
|
||||
},
|
||||
{
|
||||
"name": "software-b",
|
||||
"version": "5.6.7.8",
|
||||
"source": "package",
|
||||
},
|
||||
],
|
||||
self.assertEqual(
|
||||
device.cached_facts.data,
|
||||
{
|
||||
"software": [
|
||||
{
|
||||
"name": "software-a",
|
||||
"version": "1.2.3.4",
|
||||
"source": "package",
|
||||
},
|
||||
{
|
||||
"name": "software-b",
|
||||
"version": "5.6.7.8",
|
||||
"source": "package",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Enterprise API Views"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -37,18 +35,6 @@ class EnterpriseRequiredMixin:
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
def enterprise_action(func: Callable):
|
||||
"""Check permissions for a single custom action"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Response:
|
||||
if not LicenseKey.cached_summary().status.is_valid:
|
||||
raise ValidationError(_("Enterprise is required to use this endpoint."))
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class LicenseSerializer(ModelSerializer):
|
||||
"""License Serializer"""
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.endpoints.connectors.agent.api.agent import (
|
||||
AgentAuthenticationResponse,
|
||||
AgentTokenResponseSerializer,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceAuthenticationToken,
|
||||
DeviceToken,
|
||||
)
|
||||
from authentik.enterprise.api import enterprise_action
|
||||
from authentik.enterprise.endpoints.connectors.agent.auth import (
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -26,7 +37,6 @@ class AgentConnectorViewSetMixin:
|
||||
responses=AgentAuthenticationResponse(),
|
||||
)
|
||||
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
|
||||
@enterprise_action
|
||||
def auth_ia(self, request: Request) -> Response:
|
||||
token: DeviceToken = request.auth
|
||||
auth_token = DeviceAuthenticationToken.objects.create(
|
||||
@@ -44,3 +54,43 @@ class AgentConnectorViewSetMixin:
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
113
authentik/enterprise/endpoints/connectors/agent/auth.py
Normal file
113
authentik/enterprise/endpoints/connectors/agent/auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import get_authorization_header, validate_auth
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
@@ -16,7 +16,6 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
EnrollmentToken,
|
||||
)
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms
|
||||
|
||||
@@ -107,9 +106,3 @@ class TestAppleToken(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
event = Event.objects.filter(
|
||||
action=EventAction.LOGIN,
|
||||
app="authentik.endpoints.connectors.agent",
|
||||
).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context["device"]["name"], self.device.name)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user