Compare commits

..

1 Commits

Author SHA1 Message Date
Teffen Ellis
d07719b156 web: Fix sidebar colors, padding. 2025-11-19 15:57:09 +01:00
431 changed files with 217871 additions and 184819 deletions

View File

@@ -21,12 +21,12 @@ runs:
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@1e862dfacbd1d6d858c55d9b792c756523627244 # v5
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v5
with:
enable-cache: true
- name: Setup python
if: ${{ contains(inputs.dependencies, 'python') }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version-file: "pyproject.toml"
- name: Install Python deps
@@ -43,7 +43,7 @@ runs:
registry-url: 'https://registry.npmjs.org'
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
with:
go-version-file: "go.mod"
- name: Setup docker cache

View File

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

View File

@@ -42,7 +42,7 @@ jobs:
# Needed for checkout
contents: read
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables

View File

@@ -49,7 +49,7 @@ jobs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -69,7 +69,7 @@ jobs:
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev

View File

@@ -18,11 +18,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
@@ -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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Install Dependencies
working-directory: website/
run: npm ci
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: website/package.json
@@ -66,7 +66,7 @@ jobs:
- lint
- build
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
with:
name: api-docs

View File

@@ -21,7 +21,7 @@ jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5

View File

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

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Install dependencies
working-directory: website/
run: npm ci
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: website/package.json
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: website/package.json
@@ -69,7 +69,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU

View File

@@ -18,7 +18,7 @@ jobs:
- version-2025-4
- version-2025-2
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"

View File

@@ -37,7 +37,7 @@ jobs:
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
@@ -45,7 +45,7 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
@@ -71,12 +71,14 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- name: checkout stable
run: |
set -e -o pipefail
# Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
# Previous stable tag
@@ -87,7 +89,7 @@ jobs:
prev_stable=$current_version_family
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
git checkout $(prev_stable)
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (stable)
@@ -136,7 +138,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
@@ -156,7 +158,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
@@ -194,7 +196,7 @@ jobs:
- name: flows
glob: tests/e2e/test_flows*
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
@@ -260,7 +262,7 @@ jobs:
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables

View File

@@ -21,8 +21,8 @@ jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@@ -34,7 +34,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v8
uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v8
with:
version: latest
args: --timeout 5000s --verbose
@@ -42,8 +42,8 @@ jobs:
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- name: Setup authentik env
@@ -86,7 +86,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -145,10 +145,10 @@ jobs:
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5

View File

@@ -31,7 +31,7 @@ jobs:
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: ${{ matrix.project }}/package.json
@@ -48,7 +48,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: web/package.json
@@ -76,7 +76,7 @@ jobs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
with:
node-version-file: web/package.json

View File

@@ -29,11 +29,11 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
@@ -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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@@ -16,17 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -10,14 +10,14 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
if: ${{ env.GH_APP_ID != '' }}
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
env:
GH_APP_ID: ${{ secrets.GH_APP_ID }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
if: ${{ steps.app-token.outcome != 'skipped' }}
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Cleanup
run: |

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

View File

@@ -5,10 +5,10 @@ on:
push:
branches: [main]
paths:
- packages/tsconfig/**
- packages/docusaurus-config/**
- packages/eslint-config/**
- packages/prettier-config/**
- packages/docusaurus-config/**
- packages/tsconfig/**
- packages/esbuild-plugin-live-reload/**
workflow_dispatch:
@@ -24,14 +24,13 @@ jobs:
fail-fast: false
matrix:
package:
# The order of the `*config` packages should not be changed, as they depend on each other.
- packages/tsconfig
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/docusaurus-config
- packages/tsconfig
- packages/esbuild-plugin-live-reload
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 2
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
@@ -44,8 +43,6 @@ jobs:
with:
files: |
${{ matrix.package }}/package.json
- name: Install Dependencies
run: npm ci
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }}

View File

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

View File

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

View File

@@ -29,12 +29,12 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: main
token: "${{ steps.app-token.outputs.token }}"
@@ -57,12 +57,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: main
token: ${{ steps.generate_token.outputs.token }}
@@ -73,7 +73,7 @@ jobs:
- name: Bump version
run: "make bump version=${{ inputs.next_version }}.0-rc1"
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: release-bump-${{ inputs.next_version }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
environment: internal-production
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: main
- run: |

View File

@@ -31,7 +31,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
@@ -83,8 +83,8 @@ jobs:
- radius
- rac
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- name: Set up QEMU
@@ -146,8 +146,8 @@ jobs:
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
@@ -168,7 +168,7 @@ jobs:
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
@@ -186,8 +186,8 @@ jobs:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: ${{ env.AWS_REGION }}
@@ -202,7 +202,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
@@ -218,7 +218,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev

View File

@@ -50,7 +50,7 @@ jobs:
name: Pre-release test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- run: make test-docker
bump-authentik:
name: Bump authentik version
@@ -61,7 +61,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -70,7 +70,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
token: "${{ steps.app-token.outputs.token }}"
@@ -89,7 +89,7 @@ jobs:
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
git push --follow-tags
- name: Create Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
token: "${{ steps.app-token.outputs.token }}"
tag_name: "version/${{ inputs.version }}"
@@ -108,7 +108,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -118,7 +118,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
repository: "${{ github.repository_owner }}/helm"
token: "${{ steps.app-token.outputs.token }}"
@@ -130,7 +130,7 @@ jobs:
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
./scripts/helm-docs.sh
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -150,7 +150,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -160,7 +160,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
repository: "${{ github.repository_owner }}/version"
token: "${{ steps.app-token.outputs.token }}"
@@ -185,7 +185,7 @@ jobs:
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
mv version.new.json version.json
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

View File

@@ -21,15 +21,15 @@ jobs:
steps:
- id: generate_token
if: ${{ github.event_name != 'pull_request' }}
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
@@ -44,7 +44,7 @@ jobs:
make web-check-compile
- name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

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

View File

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

View File

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

View File

@@ -17,20 +17,21 @@ pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
UNAME := $(shell uname)
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
# These functions are only evaluated when called in specific targets
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
KRB_PATH =
ifneq ($(KRB5_EXISTS),)
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
ifeq ($(UNAME), Darwin)
# Only add for brew users who installed libxmlsec1
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
ifdef BREW_EXISTS
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
ifdef LIBXML2_EXISTS
BREW_LDFLAGS := -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
BREW_CPPFLAGS := -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
BREW_PKG_CONFIG_PATH := $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
endif
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
@@ -49,7 +50,7 @@ go-test:
go test -timeout 0 -v -race -cover ./...
test: ## Run the server tests and produce a coverage report (locally)
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
uv run coverage run manage.py test --keepdb authentik
uv run coverage html
uv run coverage report
@@ -65,11 +66,11 @@ lint: ## Lint the python and golang sources
golangci-lint run -v
core-install:
ifneq ($(LIBXML2_EXISTS),)
ifdef LIBXML2_EXISTS
# Clear cache to ensure fresh compilation
uv cache clean
# Force compilation from source for lxml and xmlsec with correct environment
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
else
uv sync --frozen
endif
@@ -196,12 +197,11 @@ endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
gen-client-go: ## Build and install the authentik API for Golang
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ${PWD}/${GEN_API_GO}
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
else
cd ${PWD}/${GEN_API_GO} && git reset --hard
cd ${PWD}/${GEN_API_GO} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}

View File

@@ -27,16 +27,16 @@ except OSError:
ipc_key = None
def validate_auth(header: bytes, format="bearer") -> str | None:
def validate_auth(header: bytes) -> str | None:
"""Validate that the header is in a correct format,
returns type and credentials"""
auth_credentials = header.decode().strip()
if auth_credentials == "" or " " not in auth_credentials:
return None
auth_type, _, auth_credentials = auth_credentials.partition(" ")
if not compare_digest(auth_type.lower(), format):
if auth_type.lower() != "bearer":
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
return None
raise AuthenticationFailed("Unsupported authentication type")
if auth_credentials == "": # nosec # noqa
raise AuthenticationFailed("Malformed header")
return auth_credentials

View File

@@ -24,7 +24,8 @@ class TestAPIAuth(TestCase):
def test_invalid_type(self):
"""Test invalid type"""
self.assertIsNone(bearer_auth(b"foo bar"))
with self.assertRaises(AuthenticationFailed):
bearer_auth(b"foo bar")
def test_invalid_empty(self):
"""Test invalid type"""
@@ -33,8 +34,9 @@ class TestAPIAuth(TestCase):
def test_invalid_no_token(self):
"""Test invalid with no token"""
auth = b64encode(b":abc").decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
with self.assertRaises(AuthenticationFailed):
auth = b64encode(b":abc").decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
def test_bearer_valid(self):
"""Test valid token"""

View File

@@ -5,7 +5,6 @@ from pathlib import Path
from django.conf import settings
from django.db import models
from django.dispatch import Signal
from django.http import HttpRequest
from drf_spectacular.utils import extend_schema
from rest_framework.fields import (
BooleanField,
@@ -64,8 +63,7 @@ class ConfigView(APIView):
permission_classes = [AllowAny]
@staticmethod
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
def get_capabilities(self) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
@@ -78,19 +76,18 @@ class ConfigView(APIView):
for processor in get_context_processors():
if cap := processor.capability():
caps.append(cap)
if request.tenant.impersonation:
if self.request.tenant.impersonation:
caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG)
if "authentik.enterprise" in settings.INSTALLED_APPS:
caps.append(Capabilities.IS_ENTERPRISE)
for _, result in capabilities.send(sender=ConfigView):
for _, result in capabilities.send(sender=self):
if result:
caps.append(result)
return caps
@staticmethod
def get_config(request: HttpRequest) -> ConfigSerializer:
def get_config(self) -> ConfigSerializer:
"""Get Config"""
return ConfigSerializer(
{
@@ -101,7 +98,7 @@ class ConfigView(APIView):
"send_pii": CONFIG.get("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
},
"capabilities": ConfigView.get_capabilities(request),
"capabilities": self.get_capabilities(),
"cache_timeout": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
@@ -111,4 +108,4 @@ class ConfigView(APIView):
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
return Response(ConfigView.get_config(request).data)
return Response(self.get_config().data)

View File

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

View File

@@ -118,10 +118,7 @@ class Command(BaseCommand):
model_instance: Model = model()
if not isinstance(model_instance, SerializerModel):
continue
try:
serializer_class = model_instance.serializer
except NotImplementedError as exc:
raise NotImplementedError(model_instance) from exc
serializer_class = model_instance.serializer
serializer = serializer_class(
context={
SERIALIZER_CONTEXT_BLUEPRINT: False,

View File

@@ -42,15 +42,6 @@ from authentik.core.models import (
User,
UserSourceConnection,
)
from authentik.endpoints.connectors.agent.models import (
AgentDeviceConnection,
AppleNonce,
DeviceAuthenticationToken,
)
from authentik.endpoints.connectors.agent.models import (
DeviceToken as EndpointDeviceToken,
)
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
@@ -121,7 +112,6 @@ def excluded_models() -> list[type[Model]]:
OutpostServiceConnection,
Policy,
PolicyBindingModel,
Connector,
# Classes that have other dependencies
Session,
AuthenticatedSession,
@@ -149,13 +139,6 @@ def excluded_models() -> list[type[Model]]:
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
EndpointDeviceToken,
Device,
DeviceConnection,
DeviceAuthenticationToken,
AppleNonce,
AgentDeviceConnection,
DeviceFactSnapshot,
DeviceToken,
StreamEvent,
UserConsent,

View File

@@ -13,7 +13,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.api.validation import validate
from authentik.core.api.users import ParamUserSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
@@ -86,7 +85,8 @@ class AdminDeviceViewSet(ViewSet):
parameters=[ParamUserSerializer],
responses={200: DeviceSerializer(many=True)},
)
@validate(ParamUserSerializer, "query")
def list(self, request: Request, query: ParamUserSerializer) -> Response:
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
return Response(DeviceSerializer(self.get_devices(**query.validated_data), many=True).data)
args = ParamUserSerializer(data=request.query_params)
args.is_valid(raise_exception=True)
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)

View File

@@ -14,7 +14,6 @@ from drf_spectacular.utils import (
extend_schema_field,
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.request import Request
@@ -23,12 +22,9 @@ 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
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
from authentik.core.models import Group, User
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
@@ -231,11 +227,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter
ordering = ["name"]
authentication_classes = [
TokenAuthentication,
SessionAuthentication,
AgentAuth,
]
def get_ql_fields(self):
from djangoql.schema import BoolField, StrField
@@ -298,14 +289,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
filter_backends=[],
permission_classes=[],
)
@validate(UserAccountSerializer)
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
def add_user(self, request: Request, pk: str) -> Response:
"""Add user to group"""
group: Group = self.get_object()
user: User = (
get_objects_for_user(request.user, "authentik_core.view_user")
.filter(
pk=body.validated_data.get("pk"),
pk=request.data.get("pk"),
)
.first()
)
@@ -329,14 +319,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
filter_backends=[],
permission_classes=[],
)
@validate(UserAccountSerializer)
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
def remove_user(self, request: Request, pk: str) -> Response:
"""Remove user from group"""
group: Group = self.get_object()
user: User = (
get_objects_for_user(request.user, "authentik_core.view_user")
.filter(
pk=body.validated_data.get("pk"),
pk=request.data.get("pk"),
)
.first()
)

View File

@@ -21,7 +21,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.api.validation import validate
from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
@@ -129,20 +128,23 @@ class PropertyMappingViewSet(
],
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
@validate(PropertyMappingTestSerializer)
def test(self, request: Request, pk: str, body: PropertyMappingTestSerializer) -> Response:
def test(self, request: Request, pk: str) -> Response:
"""Test Property Mapping"""
_mapping: PropertyMapping = self.get_object()
# Use `get_subclass` to get correct class and correct `.evaluate` implementation
mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
# FIXME: when we separate policy mappings between ones for sources
# and ones for providers, we need to make the user field optional for the source mapping
test_params = self.PropertyMappingTestSerializer(data=request.data)
if not test_params.is_valid():
return Response(test_params.errors, status=400)
format_result = str(request.GET.get("format_result", "false")).lower() == "true"
context: dict = body.validated_data.get("context", {})
context: dict = test_params.validated_data.get("context", {})
context.setdefault("user", None)
if user := body.validated_data.get("user"):
if user := test_params.validated_data.get("user"):
# User permission check, only allow mapping testing for users that are readable
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=user.pk
@@ -150,7 +152,7 @@ class PropertyMappingViewSet(
if not users.exists():
raise PermissionDenied()
context["user"] = user
if group := body.validated_data.get("group"):
if group := test_params.validated_data.get("group"):
# Group permission check, only allow mapping testing for groups that are readable
groups = get_objects_for_user(request.user, "authentik_core.view_group").filter(
pk=group.pk

View File

@@ -56,7 +56,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"name",
"slug",
"enabled",
"promoted",
"authentication_flow",
"enrollment_flow",
"user_property_mappings",

View File

@@ -3,7 +3,7 @@
from typing import Any
from django.utils.timezone import now
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm, get_anonymous_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -12,7 +12,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.api.validation import validate
from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
@@ -108,12 +107,6 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
}
class TokenSetKeySerializer(PassiveSerializer):
"""Set token's key"""
key = CharField()
class TokenViewSerializer(PassiveSerializer):
"""Show token's current key"""
@@ -177,7 +170,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
@permission_required("authentik_core.set_token_key")
@extend_schema(
request=TokenSetKeySerializer(),
request=inline_serializer(
"TokenSetKey",
{
"key": CharField(),
},
),
responses={
204: OpenApiResponse(description="Successfully changed key"),
400: OpenApiResponse(description="Missing key"),
@@ -185,12 +183,11 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
},
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
@validate(TokenSetKeySerializer)
def set_key(self, request: Request, identifier: str, body: TokenSetKeySerializer) -> Response:
def set_key(self, request: Request, identifier: str) -> Response:
"""Set token key. Action is logged as event. `authentik_core.set_token_key` permission
is required."""
token: Token = self.get_object()
key = body.validated_data.get("key")
key = request.data.get("key")
if not key:
return Response(status=400)
token.key = key

View File

@@ -12,7 +12,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from yaml import ScalarNode
from authentik.api.validation import validate
from authentik.blueprints.v1.common import (
Blueprint,
BlueprintEntry,
@@ -161,10 +160,11 @@ class TransactionalApplicationView(APIView):
200: TransactionApplicationResponseSerializer(),
},
)
@validate(TransactionApplicationSerializer)
def put(self, request: Request, body: TransactionApplicationSerializer) -> Response:
def put(self, request: Request) -> Response:
"""Convert data into a blueprint, validate it and apply it"""
blueprint: Blueprint = body.validated_data
data = TransactionApplicationSerializer(data=request.data)
data.is_valid(raise_exception=True)
blueprint: Blueprint = data.validated_data
for entry in blueprint.entries:
full_model = entry.get_model(blueprint)
app, __, model = full_model.partition(".")

View File

@@ -31,7 +31,6 @@ from drf_spectacular.utils import (
inline_serializer,
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
@@ -53,8 +52,6 @@ from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.authentication import TokenAuthentication
from authentik.api.validation import validate
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
@@ -78,7 +75,6 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken
@@ -436,11 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
serializer_class = UserSerializer
filterset_class = UsersFilter
search_fields = ["email", "name", "uuid", "username"]
authentication_classes = [
TokenAuthentication,
SessionAuthentication,
AgentAuth,
]
def get_ql_fields(self):
from djangoql.schema import BoolField, StrField
@@ -538,13 +529,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
pagination_class=None,
filter_backends=[],
)
@validate(UserServiceAccountSerializer)
def service_account(self, request: Request, body: UserServiceAccountSerializer) -> Response:
def service_account(self, request: Request) -> Response:
"""Create a new user account that is marked as a service account"""
expires = body.validated_data.get("expires", now() + timedelta(days=360))
data = UserServiceAccountSerializer(data=request.data)
data.is_valid(raise_exception=True)
expires = data.validated_data.get("expires", now() + timedelta(days=360))
username = body.validated_data["name"]
expiring = body.validated_data["expiring"]
username = data.validated_data["name"]
expiring = data.validated_data["expiring"]
with atomic():
try:
user: User = User.objects.create(
@@ -562,7 +554,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"user_uid": user.uid,
"user_pk": user.pk,
}
if body.validated_data["create_group"] and self.request.user.has_perm(
if data.validated_data["create_group"] and self.request.user.has_perm(
"authentik_core.add_group"
):
group = Group.objects.create(name=username)
@@ -633,12 +625,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
},
)
@action(detail=True, methods=["POST"], permission_classes=[])
@validate(UserPasswordSetSerializer)
def set_password(self, request: Request, pk: int, body: UserPasswordSetSerializer) -> Response:
def set_password(self, request: Request, pk: int) -> Response:
"""Set password for user"""
data = UserPasswordSetSerializer(data=request.data)
data.is_valid(raise_exception=True)
user: User = self.get_object()
try:
user.set_password(body.validated_data["password"], request=request)
user.set_password(data.validated_data["password"], request=request)
user.save()
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-23 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0051_group_authentik_c_is_supe_1e5a97_idx"),
]
operations = [
migrations.AddField(
model_name="source",
name="promoted",
field=models.BooleanField(
default=False,
help_text="When enabled, this source will be displayed as a prominent button on the login page, instead of a small icon.",
),
),
]

View File

@@ -1,45 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-25 16:36
import django.core.validators
import re
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0052_source_promoted"),
]
operations = [
migrations.AlterField(
model_name="application",
name="slug",
field=models.TextField(
help_text="Internal application name, used in URLs.",
unique=True,
validators=[
django.core.validators.RegexValidator(
re.compile("^[-a-zA-Z0-9_]+\\Z"),
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
"invalid",
)
],
),
),
migrations.AlterField(
model_name="source",
name="slug",
field=models.TextField(
help_text="Internal source name, used in URLs.",
unique=True,
validators=[
django.core.validators.RegexValidator(
re.compile("^[-a-zA-Z0-9_]+\\Z"),
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
"invalid",
)
],
),
),
]

View File

@@ -11,7 +11,6 @@ from django.contrib.auth.hashers import check_password
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
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
@@ -535,11 +534,7 @@ class Application(SerializerModel, PolicyBindingModel):
add custom fields and other properties"""
name = models.TextField(help_text=_("Application's display Name."))
slug = models.TextField(
validators=[validate_slug],
help_text=_("Internal application name, used in URLs."),
unique=True,
)
slug = models.SlugField(help_text=_("Internal application name, used in URLs."), unique=True)
group = models.TextField(blank=True, default="")
provider = models.OneToOneField(
@@ -725,22 +720,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
MANAGED_INBUILT = "goauthentik.io/sources/inbuilt"
name = models.TextField(help_text=_("Source's display Name."))
slug = models.TextField(
validators=[validate_slug],
help_text=_("Internal source name, used in URLs."),
unique=True,
)
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
enabled = models.BooleanField(default=True)
promoted = models.BooleanField(
default=False,
help_text=_(
"When enabled, this source will be displayed as a prominent button on the "
"login page, instead of a small icon."
),
)
user_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_userpropertymappings_set"
)
@@ -945,7 +929,7 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet["Self"]:
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):

View File

@@ -8,8 +8,6 @@ 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, create_test_flow
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
class TestApplicationsViews(FlowTestCase):
@@ -17,7 +15,7 @@ class TestApplicationsViews(FlowTestCase):
def setUp(self) -> None:
self.user = create_test_admin_user()
self.app = Application.objects.create(
self.allowed = Application.objects.create(
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
)
@@ -30,7 +28,7 @@ class TestApplicationsViews(FlowTestCase):
response = self.client.get(
reverse(
"authentik_core:application-launch",
kwargs={"application_slug": self.app.slug},
kwargs={"application_slug": self.allowed.slug},
),
follow=True,
)
@@ -54,63 +52,8 @@ class TestApplicationsViews(FlowTestCase):
response = self.client.get(
reverse(
"authentik_core:application-launch",
kwargs={"application_slug": self.app.slug},
kwargs={"application_slug": self.allowed.slug},
),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
def test_redirect_application_auth_flow(self):
"""Test launching an application with a provider and an authentication flow set"""
self.client.logout()
auth_flow = create_test_flow()
prov = OAuth2Provider.objects.create(
name=generate_id(),
authentication_flow=auth_flow,
)
self.app.provider = prov
self.app.save()
with self.assertFlowFinishes() as plan:
response = self.client.get(
reverse(
"authentik_core:application-launch",
kwargs={"application_slug": self.app.slug},
),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": auth_flow.slug}),
)
plan = plan()
self.assertEqual(len(plan.bindings), 1)
self.assertTrue(plan.bindings[0].stage.is_in_memory)
def test_redirect_application_no_auth(self):
"""Test launching an application with a provider and an authentication flow set"""
self.client.logout()
empty_flow = create_test_flow()
brand: Brand = create_test_brand()
brand.flow_authentication = empty_flow
brand.save()
prov = OAuth2Provider.objects.create(
name=generate_id(),
)
self.app.provider = prov
self.app.save()
with self.assertFlowFinishes() as plan:
response = self.client.get(
reverse(
"authentik_core:application-launch",
kwargs={"application_slug": self.app.slug},
),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": empty_flow.slug}),
)
plan = plan()
self.assertEqual(len(plan.bindings), 1)
self.assertTrue(plan.bindings[0].stage.is_in_memory)

View File

@@ -8,10 +8,11 @@ from guardian.utils import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import RequestFactory, create_test_flow
from authentik.core.tests.utils import create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
@@ -33,11 +34,10 @@ class TestSourceFlowManager(TestCase):
enrollment_flow=self.enrollment_flow,
)
self.identifier = generate_id()
self.request_factory = RequestFactory()
def test_unauthenticated_enroll(self):
"""Test un-authenticated user enrolling"""
request = self.request_factory.get("/", user=AnonymousUser())
request = get_request("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
)
@@ -53,7 +53,7 @@ class TestSourceFlowManager(TestCase):
UserOAuthSourceConnection.objects.create(
user=get_anonymous_user(), source=self.source, identifier=self.identifier
)
request = self.request_factory.get("/", user=AnonymousUser())
request = get_request("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
)
@@ -67,7 +67,7 @@ class TestSourceFlowManager(TestCase):
def test_authenticated_link(self):
"""Test authenticated user linking"""
user = User.objects.create(username="foo", email="foo@bar.baz")
request = self.request_factory.get("/", user=user)
request = get_request("/", user=user)
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
)
@@ -87,7 +87,7 @@ class TestSourceFlowManager(TestCase):
UserOAuthSourceConnection.objects.create(
user=user, source=self.source, identifier=self.identifier
)
request = self.request_factory.get("/", user=user)
request = get_request("/", user=user)
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
)
@@ -100,11 +100,7 @@ class TestSourceFlowManager(TestCase):
def test_unauthenticated_link(self):
"""Test un-authenticated user linking"""
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=get_anonymous_user()),
self.identifier,
{"info": {}},
{},
self.source, get_request("/"), self.identifier, {"info": {}}, {}
)
action, connection = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
@@ -118,11 +114,7 @@ class TestSourceFlowManager(TestCase):
# Without email, deny
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
self.identifier,
{"info": {}},
{},
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
@@ -130,7 +122,7 @@ class TestSourceFlowManager(TestCase):
# With email
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {
@@ -150,11 +142,7 @@ class TestSourceFlowManager(TestCase):
# Without username, deny
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
self.identifier,
{"info": {}},
{},
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
@@ -162,7 +150,7 @@ class TestSourceFlowManager(TestCase):
# With username
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
@@ -181,7 +169,7 @@ class TestSourceFlowManager(TestCase):
# With non-existent username, enroll
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {
@@ -196,7 +184,7 @@ class TestSourceFlowManager(TestCase):
# With username
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
@@ -213,7 +201,7 @@ class TestSourceFlowManager(TestCase):
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},
@@ -242,7 +230,7 @@ class TestSourceFlowManager(TestCase):
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
get_request("/", user=AnonymousUser()),
self.identifier,
{
"info": {"username": "foo"},

View File

@@ -4,9 +4,9 @@ from django.test import TestCase
from authentik.core.auth import TokenBackend
from authentik.core.models import Token, TokenIntents, User
from authentik.core.tests.utils import RequestFactory
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.tests.utils import get_request
class TestTokenAuth(TestCase):
@@ -17,9 +17,8 @@ class TestTokenAuth(TestCase):
self.token = Token.objects.create(
expiring=False, user=self.user, intent=TokenIntents.INTENT_APP_PASSWORD
)
self.request_factory = RequestFactory()
# To test with session we need to create a request and pass it through all middlewares
self.request = self.request_factory.get("/")
self.request = get_request("/")
self.request.session[SESSION_KEY_PLAN] = FlowPlan("test")
def test_token_auth(self):

View File

@@ -1,12 +1,5 @@
"""Test Utils"""
from typing import Any
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpRequest
from django.test import RequestFactory as BaseRequestFactory
from django.utils.text import slugify
from authentik.brands.models import Brand
@@ -67,45 +60,3 @@ def create_test_cert(alg=PrivateKeyAlg.RSA) -> CertificateKeyPair:
)
builder.common_name = generate_id()
return builder.save()
def dummy_get_response(request: HttpRequest): # pragma: no cover
"""Dummy get_response for SessionMiddleware"""
return None
class RequestFactory(BaseRequestFactory):
def generic(
self,
method: str,
path: str,
data: Any = "",
content_type="application/octet-stream",
secure=False,
*,
headers=None,
query_params=None,
**extra,
):
user = extra.pop("user", None)
request = super().generic(
method,
path,
data,
content_type,
secure,
headers=headers,
query_params=query_params,
**extra,
)
request.user = user if user else AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
middleware = MessageMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
return request

View File

@@ -21,9 +21,6 @@ class UILoginButton:
# Icon URL, used as-is
icon_url: str | None = None
# Whether this source should be displayed as a prominent button
promoted: bool = False
class UserSettingSerializer(PassiveSerializer):
"""Serializer for User settings for stages and sources"""

View File

@@ -16,6 +16,7 @@ from authentik.flows.models import FlowDesignation, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
ToDefaultFlow,
)
from authentik.stages.consent.stage import (
@@ -36,14 +37,10 @@ class RedirectToAppLaunch(View):
# Check if we're authenticated already, saves us the flow run
if request.user.is_authenticated:
return HttpResponseRedirect(app.get_launch_url(request.user))
self.request.session[SESSION_KEY_APPLICATION_PRE] = app
# otherwise, do a custom flow plan that includes the application that's
# being accessed, to improve usability
if app and app.provider and app.provider.authentication_flow:
flow = app.provider.authentication_flow
else:
flow = ToDefaultFlow.get_flow(
request=request, designation=FlowDesignation.AUTHENTICATION
)
flow = ToDefaultFlow(request=request, designation=FlowDesignation.AUTHENTICATION).get_flow()
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
@@ -58,8 +55,6 @@ class RedirectToAppLaunch(View):
)
except FlowNonApplicableException:
raise Http404 from None
# We redirect with an in_memory stage instead of `?next=...` as the launch URL
# might be formatted with the user, which hasn't logged in yet
plan.append_stage(in_memory_stage(RedirectToAppStage))
return plan.to_redirect(request, flow)

View File

@@ -8,6 +8,7 @@ from django.http.response import HttpResponse
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from django.views.generic.base import RedirectView, TemplateView
from rest_framework.request import Request
from authentik import authentik_build_hash
from authentik.admin.tasks import LOCAL_VERSION
@@ -46,7 +47,7 @@ class InterfaceView(TemplateView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
brand = CurrentBrandSerializer(self.request.brand)
kwargs["config_json"] = dumps(ConfigView.get_config(self.request).data)
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
kwargs["ui_theme"] = brand.data["ui_theme"]
kwargs["brand_json"] = dumps(brand.data)
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"

View File

@@ -33,7 +33,6 @@ from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import UserTypes
@@ -277,16 +276,17 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
},
)
@action(detail=False, methods=["POST"])
@validate(CertificateGenerationSerializer)
def generate(self, request: Request, body: CertificateGenerationSerializer) -> Response:
def generate(self, request: Request) -> Response:
"""Generate a new, self-signed certificate-key pair"""
raw_san = body.validated_data.get("subject_alt_name", "")
data = CertificateGenerationSerializer(data=request.data)
data.is_valid(raise_exception=True)
raw_san = data.validated_data.get("subject_alt_name", "")
sans = raw_san.split(",") if raw_san != "" else []
builder = CertificateBuilder(body.validated_data["name"])
builder.alg = body.validated_data["alg"]
builder = CertificateBuilder(data.validated_data["name"])
builder.alg = data.validated_data["alg"]
builder.build(
subject_alt_names=sans,
validity_days=int(body.validated_data["validity_days"]),
validity_days=int(data.validated_data["validity_days"]),
)
instance = builder.save()
serializer = self.get_serializer(instance)

View File

@@ -1,5 +1,7 @@
"""authentik crypto app config"""
from datetime import UTC, datetime
from dramatiq.broker import get_broker
from authentik.blueprints.apps import ManagedAppConfig
@@ -45,7 +47,10 @@ class AuthentikCryptoConfig(ManagedAppConfig):
cert: CertificateKeyPair | None = CertificateKeyPair.objects.filter(
managed=MANAGED_KEY
).first()
if not cert:
now = datetime.now(tz=UTC)
if not cert or (
now < cert.certificate.not_valid_after_utc or now > cert.certificate.not_valid_after_utc
):
self._create_update_cert()
@ManagedAppConfig.reconcile_tenant

View File

@@ -1,51 +0,0 @@
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.endpoints.models import Connector
class ConnectorSerializer(ModelSerializer, MetaNameSerializer):
component = SerializerMethodField()
def get_component(self, obj: Connector) -> str: # pragma: no cover
"""Get object component so that we know how to edit the object"""
if obj.__class__ == Connector:
return ""
return obj.component
class Meta:
model = Connector
fields = [
"connector_uuid",
"name",
"enabled",
"component",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
]
class ConnectorViewSet(
TypesMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""Connector Viewset"""
queryset = Connector.objects.none()
serializer_class = ConnectorSerializer
search_fields = [
"name",
]
def get_queryset(self): # pragma: no cover
return Connector.objects.select_subclasses()

View File

@@ -1,31 +0,0 @@
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.models import DeviceAccessGroup
class DeviceAccessGroupSerializer(ModelSerializer):
class Meta:
model = DeviceAccessGroup
fields = [
"pbm_uuid",
"name",
]
class DeviceAccessGroupViewSet(UsedByMixin, ModelViewSet):
"""DeviceAccessGroup Viewset"""
queryset = DeviceAccessGroup.objects.all()
serializer_class = DeviceAccessGroupSerializer
search_fields = [
"pbm_uuid",
"name",
]
filterset_fields = [
"pbm_uuid",
"name",
]
ordering = ["name"]

View File

@@ -1,27 +0,0 @@
from rest_framework.fields import SerializerMethodField
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.api.device_fact_snapshots import DeviceFactSnapshotSerializer
from authentik.endpoints.models import DeviceConnection
class DeviceConnectionSerializer(ModelSerializer):
connector_obj = ConnectorSerializer(source="connector", read_only=True)
latest_snapshot = SerializerMethodField(allow_null=True)
def get_latest_snapshot(self, instance: DeviceConnection) -> DeviceFactSnapshotSerializer:
snapshot = instance.devicefactsnapshot_set.order_by("-created").first()
if not snapshot:
return None
return DeviceFactSnapshotSerializer(snapshot).data
class Meta:
model = DeviceConnection
fields = [
"device",
"connector",
"connector_obj",
"latest_snapshot",
]

View File

@@ -1,21 +0,0 @@
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.facts import DeviceFacts
from authentik.endpoints.models import DeviceFactSnapshot
class DeviceFactSnapshotSerializer(ModelSerializer):
data = DeviceFacts()
class Meta:
model = DeviceFactSnapshot
fields = [
"data",
"connection",
"created",
"expires",
]
extra_kwargs = {
"created": {"read_only": True},
"expires": {"read_only": True},
}

View File

@@ -1,27 +0,0 @@
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.models import DeviceUserBinding
from authentik.policies.api.bindings import PolicyBindingSerializer, PolicyBindingViewSet
class DeviceUserBindingSerializer(PolicyBindingSerializer):
connector_obj = ConnectorSerializer(source="connector", read_only=True)
class Meta:
model = DeviceUserBinding
fields = PolicyBindingSerializer.Meta.fields + [
"is_primary",
"connector",
"connector_obj",
]
extra_kwargs = {"connector": {"read_only": True}}
class DeviceUserBindingViewSet(PolicyBindingViewSet):
"""PolicyBinding Viewset"""
queryset = (
DeviceUserBinding.objects.all()
.select_related("target", "group", "user")
.prefetch_related("policy")
) # prefetching policy so we resolve the subclass

View File

@@ -1,78 +0,0 @@
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.api.device_access_group import DeviceAccessGroupSerializer
from authentik.endpoints.api.device_connections import DeviceConnectionSerializer
from authentik.endpoints.api.device_fact_snapshots import DeviceFactSnapshotSerializer
from authentik.endpoints.models import Device
class EndpointDeviceSerializer(ModelSerializer):
access_group_obj = DeviceAccessGroupSerializer(source="access_group", required=False)
facts = SerializerMethodField()
def get_facts(self, instance: Device) -> DeviceFactSnapshotSerializer:
return DeviceFactSnapshotSerializer(instance.cached_facts).data
class Meta:
model = Device
fields = [
"device_uuid",
"pbm_uuid",
"name",
"access_group",
"access_group_obj",
"expiring",
"expires",
"facts",
"attributes",
]
class EndpointDeviceDetailsSerializer(EndpointDeviceSerializer):
connections_obj = DeviceConnectionSerializer(many=True, source="deviceconnection_set")
def get_facts(self, instance: Device) -> DeviceFactSnapshotSerializer:
return DeviceFactSnapshotSerializer(instance.facts).data
class Meta(EndpointDeviceSerializer.Meta):
fields = EndpointDeviceSerializer.Meta.fields + [
"connections_obj",
"policies",
"connections",
]
class DeviceViewSet(
UsedByMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
GenericViewSet,
):
queryset = Device.objects.all().select_related("access_group")
serializer_class = EndpointDeviceSerializer
search_fields = [
"name",
"identifier",
]
ordering = ["identifier"]
filterset_fields = ["name", "identifier"]
def get_serializer_class(self):
if self.action == "retrieve":
return EndpointDeviceDetailsSerializer
return super().get_serializer_class()
def get_queryset(self):
if self.action == "retrieve":
return super().get_queryset().prefetch_related("connections")
return super().get_queryset()

View File

@@ -1,32 +0,0 @@
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(EnterpriseRequiredMixin, StageSerializer):
"""EndpointStage Serializer"""
connector_obj = ConnectorSerializer(source="connector", read_only=True)
class Meta:
model = EndpointStage
fields = StageSerializer.Meta.fields + [
"connector",
"connector_obj",
]
class EndpointStageViewSet(UsedByMixin, ModelViewSet):
"""EndpointStage Viewset"""
queryset = EndpointStage.objects.all()
serializer_class = EndpointStageSerializer
filterset_fields = [
"name",
]
search_fields = ["name"]
ordering = ["name"]

View File

@@ -1,12 +0,0 @@
"""authentik endpoints app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikEndpointsConfig(ManagedAppConfig):
"""authentik endpoints app config"""
name = "authentik.endpoints"
label = "authentik_endpoints"
verbose_name = "authentik Endpoints"
default = True

View File

@@ -1,66 +0,0 @@
from rest_framework.fields import (
BooleanField,
CharField,
IntegerField,
SerializerMethodField,
)
from authentik.api.v3.config import ConfigSerializer, ConfigView
from authentik.core.api.utils import PassiveSerializer
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.providers.oauth2.views.jwks import JWKSView
class AgentConfigSerializer(PassiveSerializer):
device_id = SerializerMethodField()
refresh_interval = SerializerMethodField()
authorization_flow = SerializerMethodField()
jwks = SerializerMethodField()
nss_uid_offset = IntegerField()
nss_gid_offset = IntegerField()
auth_terminate_session_on_expiry = BooleanField()
system_config = SerializerMethodField()
def get_device_id(self, instance: AgentConnector) -> str:
device: Device = self.context["device"]
return device.pk
def get_refresh_interval(self, instance: AgentConnector) -> int:
return int(timedelta_from_string(instance.refresh_interval).total_seconds())
def get_authorization_flow(self, instance: AgentConnector) -> str | None:
if not instance.authorization_flow:
return None
return instance.authorization_flow.slug
def get_jwks(self, instance: AgentConnector) -> dict:
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
return {"keys": [JWKSView.get_jwk_for_key(kp, "sig")]}
def get_system_config(self, instance: AgentConnector) -> ConfigSerializer:
return ConfigView.get_config(self.context["request"]).data
class EnrollSerializer(PassiveSerializer):
device_serial = CharField(required=True)
device_name = CharField(required=True)
class AgentTokenResponseSerializer(PassiveSerializer):
token = CharField(required=True)
expires_in = IntegerField(required=0)
class AgentAuthenticationResponse(PassiveSerializer):
url = CharField()

View File

@@ -1,169 +0,0 @@
from typing import cast
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
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 (
CharField,
ChoiceField,
)
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.connectors.agent.api.agent import (
AgentConfigSerializer,
AgentTokenResponseSerializer,
EnrollSerializer,
)
from authentik.endpoints.connectors.agent.auth import (
AgentAuth,
AgentEnrollmentAuth,
)
from authentik.endpoints.connectors.agent.models import (
AgentConnector,
AgentDeviceConnection,
DeviceToken,
EnrollmentToken,
)
from authentik.endpoints.facts import DeviceFacts, OSFamily
from authentik.endpoints.models import Device
from authentik.lib.utils.reflection import ConditionalInheritance
class AgentConnectorSerializer(ConnectorSerializer):
class Meta(ConnectorSerializer.Meta):
model = AgentConnector
fields = ConnectorSerializer.Meta.fields + [
"snapshot_expiry",
"auth_session_duration",
"auth_terminate_session_on_expiry",
"refresh_interval",
"authorization_flow",
"nss_uid_offset",
"nss_gid_offset",
"challenge_key",
"jwt_federation_providers",
]
class MDMConfigSerializer(PassiveSerializer):
platform = ChoiceField(choices=OSFamily.choices)
enrollment_token = PrimaryKeyRelatedField(queryset=EnrollmentToken.objects.all())
def validate_platform(self, platform: OSFamily) -> OSFamily:
if platform not in [OSFamily.iOS, OSFamily.macOS, OSFamily.windows]:
raise ValidationError(_("Selected platform not supported"))
return platform
def validate_enrollment_token(self, token: EnrollmentToken) -> EnrollmentToken:
if token.is_expired:
raise ValidationError(_("Token is expired"))
if token.connector != self.context["connector"]:
raise ValidationError(_("Invalid token for connector"))
return token
class MDMConfigResponseSerializer(PassiveSerializer):
config = CharField(required=True)
class AgentConnectorViewSet(
ConditionalInheritance(
"authentik.enterprise.endpoints.connectors.agent.api.connectors.AgentConnectorViewSetMixin"
),
UsedByMixin,
ModelViewSet,
):
queryset = AgentConnector.objects.all()
serializer_class = AgentConnectorSerializer
search_fields = ["name"]
ordering = ["name"]
filterset_fields = ["name", "enabled"]
@extend_schema(
request=MDMConfigSerializer(),
responses=MDMConfigResponseSerializer(),
)
@action(methods=["POST"], detail=True)
def mdm_config(self, request: Request, pk) -> Response:
"""Generate configuration for MDM systems to deploy authentik Agent"""
connector = cast(AgentConnector, self.get_object())
data = MDMConfigSerializer(data=request.data, context={"connector": connector})
data.is_valid(raise_exception=True)
token = data.validated_data["enrollment_token"]
if not request.user.has_perm("view_enrollment_token_key", token):
raise PermissionDenied()
ctrl = connector.controller(connector)
payload = ctrl.generate_mdm_config(data.validated_data["platform"], request, token)
return Response({"config": payload})
@extend_schema(
request=EnrollSerializer(),
responses={200: AgentTokenResponseSerializer},
)
@action(
methods=["POST"],
detail=False,
authentication_classes=[AgentEnrollmentAuth],
)
def enroll(self, request: Request):
token: EnrollmentToken = request.auth
data = EnrollSerializer(data=request.data)
data.is_valid(raise_exception=True)
device, _ = Device.objects.get_or_create(
identifier=data.validated_data["device_serial"],
defaults={
"name": data.validated_data["device_name"],
"expiring": False,
"access_group": token.device_group,
},
)
connection, _ = AgentDeviceConnection.objects.update_or_create(
device=device,
connector=token.connector,
)
token = DeviceToken.objects.create(device=connection, expiring=False)
return Response(
{
"token": token.key,
"expires_in": 0,
}
)
@extend_schema(
request=OpenApiTypes.NONE,
responses=AgentConfigSerializer(),
)
@action(methods=["GET"], detail=False, authentication_classes=[AgentAuth])
def agent_config(self, request: Request):
token: DeviceToken = request.auth
connector: AgentConnector = token.device.connector.agentconnector
return Response(
AgentConfigSerializer(
connector, context={"request": request, "device": token.device.device}
).data
)
@extend_schema(
request=DeviceFacts(),
responses={204: OpenApiResponse(description="Successfully checked in")},
)
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
def check_in(self, request: Request):
token: DeviceToken = request.auth
data = DeviceFacts(data=request.data)
data.is_valid(raise_exception=True)
connection: AgentDeviceConnection = token.device
connection.create_snapshot(data.validated_data)
return Response(status=204)

View File

@@ -1,58 +0,0 @@
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.tokens import TokenViewSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.api.device_access_group import DeviceAccessGroupSerializer
from authentik.endpoints.connectors.agent.models import EnrollmentToken
from authentik.events.models import Event, EventAction
from authentik.rbac.decorators import permission_required
class EnrollmentTokenSerializer(ModelSerializer):
device_group_obj = DeviceAccessGroupSerializer(
source="device_group", read_only=True, required=False
)
class Meta:
model = EnrollmentToken
fields = [
"token_uuid",
"device_group",
"device_group_obj",
"connector",
"name",
"expiring",
"expires",
]
class EnrollmentTokenViewSet(UsedByMixin, ModelViewSet):
queryset = EnrollmentToken.objects.all().prefetch_related("device_group")
serializer_class = EnrollmentTokenSerializer
search_fields = [
"name",
"connector__name",
]
ordering = ["token_uuid"]
filterset_fields = ["token_uuid", "connector"]
@permission_required("authentik_endpoints_connectors_agent.view_enrollment_token_key")
@extend_schema(
responses={
200: TokenViewSerializer(many=False),
404: OpenApiResponse(description="Token not found or expired"),
}
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
def view_key(self, request: Request, pk: str) -> Response:
"""Return token key and log access"""
token: EnrollmentToken = self.get_object()
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
return Response(TokenViewSerializer({"key": token.key}).data)

View File

@@ -1,12 +0,0 @@
"""authentik endpoints app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikEndpointsConnectorAgentAppConfig(ManagedAppConfig):
"""authentik endpoints app config"""
name = "authentik.endpoints.connectors.agent"
label = "authentik_endpoints_connectors_agent"
verbose_name = "authentik Endpoints.Connectors.Agent"
default = True

View File

@@ -1,42 +0,0 @@
from typing import Any
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from authentik.api.authentication import IPCUser, validate_auth
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import User
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
class DeviceUser(IPCUser):
username = "authentik:endpoints:device"
class AgentEnrollmentAuth(BaseAuthentication):
def authenticate(self, request: Request) -> tuple[User, Any] | None:
auth = get_authorization_header(request)
key = validate_auth(auth)
token = EnrollmentToken.filter_not_expired(key=key).first()
if not token:
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token_enrollment")
return (DeviceUser(), token)
class AgentAuth(BaseAuthentication):
def authenticate(self, request: Request) -> tuple[User, Any] | None:
auth = get_authorization_header(request)
key = validate_auth(auth, format="bearer+agent")
if not key:
return None
device_token = DeviceToken.filter_not_expired(key=key).first()
if not device_token:
raise PermissionDenied()
if device_token.device.device.is_expired:
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token")
return (DeviceUser(), device_token)

View File

@@ -1,131 +0,0 @@
from plistlib import PlistFormat, dumps
from uuid import uuid4
from xml.etree.ElementTree import Element, SubElement, tostring # nosec
from django.http import HttpRequest
from django.urls import reverse
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
from authentik.endpoints.controller import BaseController
from authentik.endpoints.facts import OSFamily
def csp_create_replace_item(loc_uri, data_value) -> Element:
"""Create a Replace/Item element with the specified LocURI and Data"""
replace = Element("Replace")
item = SubElement(replace, "Item")
# Meta section
meta = SubElement(item, "Meta")
format_elem = SubElement(meta, "Format")
format_elem.set("xmlns", "syncml:metinf")
format_elem.text = "chr"
# Target section
target = SubElement(item, "Target")
loc_uri_elem = SubElement(target, "LocURI")
loc_uri_elem.text = loc_uri
# Data section
data = SubElement(item, "Data")
data.text = data_value
return replace
class AgentConnectorController(BaseController[AgentConnector]):
def supported_enrollment_methods(self):
return []
def generate_mdm_config(
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken
) -> str:
if target_platform == OSFamily.windows:
return self._generate_mdm_config_windows(request, token)
if target_platform in [OSFamily.iOS, OSFamily.macOS]:
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) -> str:
base_uri = (
"./Vendor/MSFT/Registry/HKLM/SOFTWARE/authentik Security Inc./Platform/ManagedConfig"
)
token_item = csp_create_replace_item(
base_uri + "/RegistrationToken",
token.key,
)
url_item = csp_create_replace_item(
base_uri + "/URL",
request.build_absolute_uri(reverse("authentik_core:root-redirect")),
)
payload = tostring(token_item, encoding="unicode") + tostring(url_item, encoding="unicode")
return payload
def _generate_mdm_config_macos(self, request: HttpRequest, token: EnrollmentToken) -> str:
token_uuid = str(token.pk).upper()
payload = dumps(
{
"PayloadContent": [
# Config for authentik Platform Agent (sysd)
{
"PayloadDisplayName": "authentik Platform",
"PayloadIdentifier": f"io.goauthentik.platform.{token_uuid}",
"PayloadType": "io.goauthentik.platform",
"PayloadUUID": str(uuid4()),
"PayloadVersion": 1,
"RegistrationToken": token.key,
"URL": request.build_absolute_uri(reverse("authentik_core:root-redirect")),
},
# Config for MDM-associated domains (required for PSSO)
{
"PayloadDisplayName": "Associated Domains",
"PayloadIdentifier": f"com.apple.associated-domains.{token_uuid}",
"PayloadType": "com.apple.associated-domains",
"PayloadUUID": str(uuid4()),
"PayloadVersion": 1,
"Configuration": [
{
"ApplicationIdentifier": "232G855Y8N.io.goauthentik.platform.agent",
"AssociatedDomains": [f"authsrv:{request.get_host()}"],
"EnableDirectDownloads": False,
}
],
},
# Config for Platform SSO
{
"PayloadDisplayName": "Platform Single Sign-On",
"PayloadIdentifier": f"com.apple.extensiblesso.{token_uuid}",
"PayloadType": "com.apple.extensiblesso",
"PayloadUUID": str(uuid4()),
"PayloadVersion": 1,
"ExtensionIdentifier": "io.goauthentik.platform.psso",
"TeamIdentifier": "232G855Y8N",
"Type": "Redirect",
"URLs": [request.build_absolute_uri("")],
"PlatformSSO": {
"AccountDisplayName": "authentik",
"AllowDeviceIdentifiersInAttestation": True,
"AuthenticationMethod": "UserSecureEnclaveKey",
"EnableAuthorization": True,
"EnableCreateUserAtLogin": True,
"FileVaultPolicy": ["RequireAuthentication"],
"LoginPolicy": ["RequireAuthentication"],
"NewUserAuthorizationMode": "Standard",
"UnlockPolicy": ["RequireAuthentication"],
"UseSharedDeviceKeys": True,
"UserAuthorizationMode": "Standard",
},
},
],
"PayloadDisplayName": "authentik Platform",
"PayloadIdentifier": str(self.connector.pk).upper(),
"PayloadScope": "System",
"PayloadType": "Configuration",
"PayloadUUID": str(self.connector.pk).upper(),
"PayloadVersion": 1,
},
fmt=PlistFormat.FMT_XML,
).decode()
return payload

View File

@@ -1,190 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-20 20:14
import authentik.core.models
import authentik.lib.generators
import authentik.lib.utils.time
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
("authentik_endpoints", "0001_initial"),
("authentik_flows", "0028_flowtoken_revoke_on_execution"),
]
operations = [
migrations.CreateModel(
name="AgentDeviceConnection",
fields=[
(
"deviceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_endpoints.deviceconnection",
),
),
("apple_signing_key", models.TextField()),
("apple_encryption_key", models.TextField()),
("apple_key_exchange_key", models.TextField()),
("apple_sign_key_id", models.TextField()),
("apple_enc_key_id", models.TextField()),
],
options={
"abstract": False,
},
bases=("authentik_endpoints.deviceconnection",),
),
migrations.CreateModel(
name="AgentDeviceUserBinding",
fields=[
(
"deviceuserbinding_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_endpoints.deviceuserbinding",
),
),
("apple_secure_enclave_key", models.TextField()),
("apple_enclave_key_id", models.TextField()),
],
options={
"abstract": False,
},
bases=("authentik_endpoints.deviceuserbinding",),
),
migrations.CreateModel(
name="AgentConnector",
fields=[
(
"connector_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_endpoints.connector",
),
),
("nss_uid_offset", models.PositiveIntegerField(default=1000)),
("nss_gid_offset", models.PositiveIntegerField(default=1000)),
("auth_terminate_session_on_expiry", models.BooleanField(default=False)),
(
"refresh_interval",
models.TextField(
default="minutes=30",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
(
"authentication_flow",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_flows.flow",
),
),
(
"challenge_key",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_crypto.certificatekeypair",
),
),
],
options={
"verbose_name": "Agent Connector",
"verbose_name_plural": "Agent Connectors",
},
bases=("authentik_endpoints.connector",),
),
migrations.CreateModel(
name="DeviceToken",
fields=[
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"token_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("key", models.TextField(default=authentik.lib.generators.generate_key)),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints_connectors_agent.agentdeviceconnection",
),
),
],
options={
"abstract": False,
"indexes": [
models.Index(fields=["expires"], name="authentik_e_expires_675725_idx"),
models.Index(fields=["expiring"], name="authentik_e_expirin_8eb6d1_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_bd42e6_idx"
),
],
},
),
migrations.CreateModel(
name="EnrollmentToken",
fields=[
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"token_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.TextField()),
("key", models.TextField(default=authentik.core.models.default_token_key)),
(
"connector",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints_connectors_agent.agentconnector",
),
),
(
"device_group",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_endpoints.devicegroup",
),
),
],
options={
"verbose_name": "Enrollment Token",
"verbose_name_plural": "Enrollment Tokens",
"permissions": [("view_enrollment_token_key", "View token's key")],
"indexes": [
models.Index(fields=["expires"], name="authentik_e_expires_98b99c_idx"),
models.Index(fields=["expiring"], name="authentik_e_expirin_9a17f7_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_7ad82a_idx"
),
models.Index(fields=["key"], name="authentik_e_key_8dafaf_idx"),
],
},
),
]

View File

@@ -1,94 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-27 00:16
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_endpoints", "0002_rename_devicegroup_deviceaccessgroup_and_more"),
("authentik_endpoints_connectors_agent", "0001_initial"),
(
"authentik_providers_oauth2",
"0031_remove_oauth2provider_backchannel_logout_uri_and_more",
),
]
operations = [
migrations.CreateModel(
name="DeviceAuthenticationToken",
fields=[
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"identifier",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("token", models.TextField()),
],
options={
"verbose_name": "Device authentication token",
"verbose_name_plural": "Device authentication tokens",
"abstract": False,
},
),
migrations.AlterModelOptions(
name="devicetoken",
options={"verbose_name": "Device Token", "verbose_name_plural": "Device Tokens"},
),
migrations.RenameField(
model_name="agentconnector",
old_name="authentication_flow",
new_name="authorization_flow",
),
migrations.AddField(
model_name="agentconnector",
name="jwt_federation_providers",
field=models.ManyToManyField(
blank=True, default=None, to="authentik_providers_oauth2.oauth2provider"
),
),
migrations.AddIndex(
model_name="devicetoken",
index=models.Index(fields=["key"], name="authentik_e_key_504bbc_idx"),
),
migrations.AddField(
model_name="deviceauthenticationtoken",
name="connector",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints_connectors_agent.agentconnector",
),
),
migrations.AddField(
model_name="deviceauthenticationtoken",
name="device",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_endpoints.device"
),
),
migrations.AddField(
model_name="deviceauthenticationtoken",
name="device_token",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints_connectors_agent.devicetoken",
),
),
migrations.AddIndex(
model_name="deviceauthenticationtoken",
index=models.Index(fields=["expires"], name="authentik_e_expires_d52fb2_idx"),
),
migrations.AddIndex(
model_name="deviceauthenticationtoken",
index=models.Index(fields=["expiring"], name="authentik_e_expirin_e9b873_idx"),
),
migrations.AddIndex(
model_name="deviceauthenticationtoken",
index=models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_8c95fe_idx"
),
),
]

View File

@@ -1,70 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-30 22:04
import authentik.lib.utils.time
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_endpoints_connectors_agent",
"0002_deviceauthenticationtoken_alter_devicetoken_options_and_more",
),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="agentconnector",
name="auth_session_duration",
field=models.TextField(
default="hours=8", validators=[authentik.lib.utils.time.timedelta_string_validator]
),
),
migrations.AddField(
model_name="deviceauthenticationtoken",
name="user",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
migrations.CreateModel(
name="AppleNonce",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("nonce", models.TextField()),
(
"device_token",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints_connectors_agent.devicetoken",
),
),
],
options={
"verbose_name": "Apple Nonce",
"verbose_name_plural": "Apple Nonces",
"abstract": False,
"indexes": [
models.Index(fields=["expires"], name="authentik_e_expires_e5d275_idx"),
models.Index(fields=["expiring"], name="authentik_e_expirin_0b4d8e_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_355561_idx"
),
],
},
),
]

View File

@@ -1,165 +0,0 @@
from typing import TYPE_CHECKING
from uuid import uuid4
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.core.models import ExpiringModel, User, default_token_key
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.models import (
Connector,
Device,
DeviceAccessGroup,
DeviceConnection,
DeviceUserBinding,
)
from authentik.flows.stage import StageView
from authentik.lib.generators import generate_key
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
if TYPE_CHECKING:
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
class AgentConnector(Connector):
"""Configure authentication and add device compliance using the authentik Agent."""
refresh_interval = models.TextField(
default="minutes=30",
validators=[timedelta_string_validator],
)
auth_session_duration = models.TextField(
default="hours=8", validators=[timedelta_string_validator]
)
auth_terminate_session_on_expiry = models.BooleanField(default=False)
authorization_flow = models.ForeignKey(
"authentik_flows.Flow", null=True, on_delete=models.SET_DEFAULT, default=None
)
jwt_federation_providers = models.ManyToManyField(
"authentik_providers_oauth2.OAuth2Provider", blank=True, default=None
)
nss_uid_offset = models.PositiveIntegerField(default=1000)
nss_gid_offset = models.PositiveIntegerField(default=1000)
challenge_key = models.ForeignKey(CertificateKeyPair, on_delete=models.CASCADE, null=True)
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.connectors.agent.api.connectors import (
AgentConnectorSerializer,
)
return AgentConnectorSerializer
@property
def stage(self) -> type[StageView] | None:
from authentik.endpoints.connectors.agent.stage import (
AuthenticatorEndpointStageView,
)
return AuthenticatorEndpointStageView
@property
def controller(self) -> type["AgentConnectorController"]:
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
return AgentConnectorController
@property
def component(self) -> str:
return "ak-endpoints-connector-agent-form"
class Meta:
verbose_name = _("Agent Connector")
verbose_name_plural = _("Agent Connectors")
class AgentDeviceConnection(DeviceConnection):
apple_key_exchange_key = models.TextField()
apple_encryption_key = models.TextField()
apple_enc_key_id = models.TextField()
apple_signing_key = models.TextField()
apple_sign_key_id = models.TextField()
class AgentDeviceUserBinding(DeviceUserBinding):
apple_secure_enclave_key = models.TextField()
apple_enclave_key_id = models.TextField()
class DeviceToken(ExpiringModel):
"""Per-device token used for authentication."""
token_uuid = models.UUIDField(primary_key=True, default=uuid4)
device = models.ForeignKey(AgentDeviceConnection, on_delete=models.CASCADE)
key = models.TextField(default=generate_key)
class Meta:
verbose_name = _("Device Token")
verbose_name_plural = _("Device Tokens")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["key"]),
]
class EnrollmentToken(ExpiringModel, SerializerModel):
"""Token used during enrollment, a device will receive
a device token for further authentication"""
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField()
key = models.TextField(default=default_token_key)
connector = models.ForeignKey(AgentConnector, on_delete=models.CASCADE)
device_group = models.ForeignKey(
DeviceAccessGroup, on_delete=models.SET_DEFAULT, default=None, null=True
)
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.connectors.agent.api.enrollment_tokens import (
EnrollmentTokenSerializer,
)
return EnrollmentTokenSerializer
class Meta:
verbose_name = _("Enrollment Token")
verbose_name_plural = _("Enrollment Tokens")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["key"]),
]
permissions = [
("view_enrollment_token_key", _("View token's key")),
]
class DeviceAuthenticationToken(ExpiringModel):
identifier = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey(Device, on_delete=models.CASCADE)
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)
connector = models.ForeignKey(AgentConnector, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, default=None)
token = models.TextField()
def __str__(self):
return f"Device authentication token {self.identifier}"
class Meta(ExpiringModel.Meta):
verbose_name = _("Device authentication token")
verbose_name_plural = _("Device authentication tokens")
class AppleNonce(ExpiringModel):
nonce = models.TextField()
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)
class Meta(ExpiringModel.Meta):
verbose_name = _("Apple Nonce")
verbose_name_plural = _("Apple Nonces")

View File

@@ -1,76 +0,0 @@
from typing import Any
from django.http import HttpResponse
from jwt import PyJWTError, decode, encode
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
from authentik.endpoints.models import Device
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
)
from authentik.flows.stage import ChallengeStageView
from authentik.lib.generators import generate_id
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
QS_CHALLENGE = "challenge"
QS_CHALLENGE_RESPONSE = "response"
class EndpointAgentChallenge(Challenge):
component = CharField(default="ak-stage-endpoint-agent")
challenge = CharField()
class EndpointAgentChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-endpoint-agent")
response = CharField()
def validate_response(self, response: str) -> dict[str, Any]:
raw = decode(
response, options={"verify_signature": False}, issuer="goauthentik.io/platform/endpoint"
)
device = Device.objects.filter(identifier=raw["sub"]).first()
if not device:
raise ValidationError("Device not found")
try:
return decode(
response,
key=device.token,
issuer="goauthentik.io/platform/endpoint",
audience=device,
)
except PyJWTError:
raise ValidationError("Invalid challenge response") from None
class AuthenticatorEndpointStageView(ChallengeStageView):
"""Endpoint stage"""
response_class = EndpointAgentChallengeResponse
def validate_response(self, response: str):
pass
def get_challenge(self, *args, **kwargs) -> Challenge:
challenge_str = generate_id()
challenge = encode(
{
"atc": challenge_str,
"iss": str(self.executor.current_stage.pk),
},
key=self.executor.current_stage.connector.challenge_key.private_key,
)
self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
return EndpointAgentChallenge(
data={
"component": "ak-stage-endpoint-agent",
"challenge": challenge,
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@@ -1,185 +0,0 @@
from datetime import timedelta
from django.urls import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.blueprints.tests import reconcile_app
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.api.connectors import AgentDeviceConnection
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
from authentik.endpoints.facts import OSFamily
from authentik.endpoints.models import Device, DeviceAccessGroup
from authentik.lib.generators import generate_id
CHECK_IN_DATA_VALID = {
"disks": [],
"hardware": {
"cpu_count": 10,
"cpu_name": "Apple M1 Pro",
"manufacturer": "Apple Inc.",
"memory_bytes": 34359738368,
"model": "MacBookPro18,1",
"serial": generate_id(),
},
"network": {
"firewall_enabled": True,
"hostname": "jens-mbp.lab.beryju.org",
"interfaces": [],
},
"os": {"arch": "arm64", "family": "mac_os", "name": "macOS", "version": "15.7.1"},
"processes": [],
"vendor": {"io.goauthentik.platform": {"agent_version": "0.23.0-dev-8521"}},
}
class TestAgentAPI(APITestCase):
def setUp(self):
self.connector = AgentConnector.objects.create(name=generate_id())
self.token = EnrollmentToken.objects.create(name=generate_id(), connector=self.connector)
self.device = Device.objects.create(
identifier=generate_id(),
)
self.connection = AgentDeviceConnection.objects.create(
device=self.device,
connector=self.connector,
)
self.device_token = DeviceToken.objects.create(
device=self.connection,
key=generate_id(),
)
def test_enroll(self):
response = self.client.post(
reverse("authentik_api:agentconnector-enroll"),
data={"device_serial": generate_id(), "device_name": "bar"},
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_enroll_group(self):
device_group = DeviceAccessGroup.objects.create(name=generate_id())
self.token.device_group = device_group
self.token.save()
ident = generate_id()
response = self.client.post(
reverse("authentik_api:agentconnector-enroll"),
data={"device_serial": ident, "device_name": "bar"},
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
device = Device.objects.filter(identifier=ident).first()
self.assertIsNotNone(device)
self.assertEqual(device.access_group, device_group)
def test_enroll_expired(self):
dev_id = generate_id()
self.token.expiring = True
self.token.expires = now() - timedelta(hours=1)
self.token.save()
response = self.client.post(
reverse("authentik_api:agentconnector-enroll"),
data={"device_serial": dev_id, "device_name": "bar"},
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(Device.objects.filter(identifier=dev_id).exists())
@reconcile_app("authentik_crypto")
def test_config(self):
response = self.client.get(
reverse("authentik_api:agentconnector-agent-config"),
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 200)
def test_check_in(self):
response = self.client.post(
reverse("authentik_api:agentconnector-check-in"),
data=CHECK_IN_DATA_VALID,
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 204)
def test_check_in_token_expired(self):
self.device_token.expiring = True
self.device_token.expires = now() - timedelta(hours=1)
self.device_token.save()
response = self.client.post(
reverse("authentik_api:agentconnector-check-in"),
data=CHECK_IN_DATA_VALID,
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 403)
def test_check_in_device_expired(self):
self.device.expiring = True
self.device.expires = now() - timedelta(hours=1)
self.device.save()
response = self.client.post(
reverse("authentik_api:agentconnector-check-in"),
data=CHECK_IN_DATA_VALID,
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 403)
def test_mdm_api_wrong_platform(self):
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse(
"authentik_api:agentconnector-mdm-config",
kwargs={
"pk": self.connector.pk,
},
),
data={"platform": OSFamily.android, "enrollment_token": self.token.pk},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(res.content, {"platform": ["Selected platform not supported"]})
def test_mdm_api_wrong_token(self):
self.client.force_login(create_test_admin_user())
other_connector = AgentConnector.objects.create(name=generate_id())
self.token.connector = other_connector
self.token.save()
res = self.client.post(
reverse(
"authentik_api:agentconnector-mdm-config",
kwargs={
"pk": self.connector.pk,
},
),
data={"platform": OSFamily.macOS, "enrollment_token": self.token.pk},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(res.content, {"enrollment_token": ["Invalid token for connector"]})
def test_mdm_api_expired_token(self):
self.client.force_login(create_test_admin_user())
self.token.expires = now() - timedelta(hours=1)
self.token.save()
res = self.client.post(
reverse(
"authentik_api:agentconnector-mdm-config",
kwargs={
"pk": self.connector.pk,
},
),
data={"platform": OSFamily.macOS, "enrollment_token": self.token.pk},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(res.content, {"enrollment_token": ["Token is expired"]})
def test_mdm_api(self):
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse(
"authentik_api:agentconnector-mdm-config",
kwargs={
"pk": self.connector.pk,
},
),
data={"platform": OSFamily.macOS, "enrollment_token": self.token.pk},
)
self.assertEqual(res.status_code, 200)

View File

@@ -1,39 +0,0 @@
from plistlib import PlistFormat, loads
from defusedxml.lxml import fromstring
from django.test import RequestFactory
from rest_framework.test import APITestCase
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
from authentik.endpoints.facts import OSFamily
from authentik.lib.generators import generate_id
class TestAgentConnector(APITestCase):
def setUp(self):
self.connector = AgentConnector.objects.create(
name=generate_id(),
)
self.token = EnrollmentToken.objects.create(name=generate_id(), connector=self.connector)
self.factory = RequestFactory()
def test_generate_mdm_macos(self):
request = self.factory.get("/")
res = self.connector.controller(self.connector).generate_mdm_config(
OSFamily.macOS, request, self.token
)
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/")
def test_generate_mdm_windows(self):
request = self.factory.get("/")
res = self.connector.controller(self.connector).generate_mdm_config(
OSFamily.windows, request, self.token
)
self.assertIsNotNone(res)
fromstring(f"<root>{res}</root>")
self.assertIn(self.token.key, res)
self.assertIn("http://testserver/", res)

View File

@@ -1,7 +0,0 @@
from authentik.endpoints.connectors.agent.api.connectors import AgentConnectorViewSet
from authentik.endpoints.connectors.agent.api.enrollment_tokens import EnrollmentTokenViewSet
api_urlpatterns = [
("endpoints/agents/connectors", AgentConnectorViewSet),
("endpoints/agents/enrollment_tokens", EnrollmentTokenViewSet),
]

View File

@@ -1,38 +0,0 @@
from django.db import models
from structlog.stdlib import BoundLogger, get_logger
from authentik.endpoints.models import Connector
from authentik.flows.stage import StageView
from authentik.lib.sentry import SentryIgnoredException
class EnrollmentMethods(models.TextChoices):
# Automatically enrolled through user action
AUTOMATIC_USER = "automatic_user"
# Automatically enrolled through connector integration
AUTOMATIC_API = "automatic_api"
# Manually enrolled with user interaction (user scanning a QR code for example)
MANUAL_USER = "manual_user"
class ConnectorSyncException(SentryIgnoredException):
"""Base exceptions for errors during sync"""
class BaseController[T: "Connector"]:
connector: T
logger: BoundLogger
def __init__(self, connector: T) -> None:
self.connector = connector
self.logger = get_logger().bind(connector=connector.name)
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
return []
def stage_view_enrollment(self) -> StageView | None:
return None
def stage_view_authentication(self) -> StageView | None:
return None

View File

@@ -1,115 +0,0 @@
from django.db.models import TextChoices
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.plumbing import build_basic_type
from drf_spectacular.types import OpenApiTypes
from rest_framework.serializers import (
BooleanField,
CharField,
ChoiceField,
IntegerField,
ListField,
Serializer,
)
from authentik.core.api.utils import JSONDictField
class BigIntegerFieldFix(OpenApiSerializerFieldExtension):
target_class = "authentik.endpoints.facts.BigIntegerField"
def map_serializer_field(self, auto_schema, direction):
return build_basic_type(OpenApiTypes.INT64)
class BigIntegerField(IntegerField): ...
class OSFamily(TextChoices):
linux = "linux"
unix = "unix"
bsd = "bsd"
windows = "windows"
macOS = "mac_os"
android = "android"
iOS = "i_os"
other = "other"
class DiskSerializer(Serializer):
name = CharField(required=True)
mountpoint = CharField(required=True)
label = CharField(required=False, allow_blank=True)
capacity_total_bytes = BigIntegerField(required=False)
capacity_used_bytes = BigIntegerField(required=False)
encryption_enabled = BooleanField(default=False, required=False)
class OperatingSystemSerializer(Serializer):
family = ChoiceField(OSFamily.choices, required=True)
name = CharField(required=False)
version = CharField(required=False)
arch = CharField(required=True)
class NetworkInterfaceSerializer(Serializer):
name = CharField(required=True)
hardware_address = CharField(required=True)
ip_addresses = ListField(child=CharField(), required=False)
dns_servers = ListField(child=CharField(), required=False, allow_empty=True)
class NetworkSerializer(Serializer):
hostname = CharField()
firewall_enabled = BooleanField(required=False)
interfaces = ListField(child=NetworkInterfaceSerializer(), allow_empty=True)
gateway = CharField(required=False)
class HardwareSerializer(Serializer):
model = CharField(required=False)
manufacturer = CharField(required=False)
serial = CharField()
cpu_name = CharField(required=False)
cpu_count = IntegerField(required=False)
memory_bytes = BigIntegerField(required=False)
class SoftwareSerializer(Serializer):
name = CharField(required=True)
version = CharField(required=False, allow_blank=True)
# Package manager/source for this software installation
source = CharField(required=True)
path = CharField(required=False)
class ProcessSerializer(Serializer):
id = IntegerField(required=True)
name = CharField()
user = CharField(required=False)
class DeviceUserSerializer(Serializer):
id = CharField(required=True)
username = CharField(required=False)
name = CharField(required=False)
home = CharField(required=False)
class DeviceGroupSerializer(Serializer):
id = CharField(required=True)
name = CharField(required=False)
class DeviceFacts(Serializer):
os = OperatingSystemSerializer(required=False, allow_null=True)
disks = ListField(child=DiskSerializer(), required=False, allow_null=True)
network = NetworkSerializer(required=False, allow_null=True)
hardware = HardwareSerializer(required=False, allow_null=True)
software = ListField(child=SoftwareSerializer(), required=False, allow_null=True)
processes = ListField(child=ProcessSerializer(), required=False, allow_null=True)
users = ListField(child=DeviceUserSerializer(), required=False, allow_null=True)
groups = ListField(child=DeviceGroupSerializer(), required=False, allow_null=True)
vendor = JSONDictField(required=False)

View File

@@ -1,228 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-19 17:50
import authentik.lib.models
import authentik.lib.utils.time
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0028_flowtoken_revoke_on_execution"),
("authentik_policies", "0011_policybinding_failure_result_and_more"),
]
operations = [
migrations.CreateModel(
name="Connector",
fields=[
(
"connector_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("name", models.TextField()),
("enabled", models.BooleanField(default=True)),
(
"snapshot_expiry",
models.TextField(
default="hours=24",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Device",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
to="authentik_policies.policybindingmodel",
),
),
("attributes", models.JSONField(blank=True, default=dict)),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"device_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("name", models.TextField()),
("identifier", models.TextField(unique=True)),
],
options={
"abstract": False,
},
bases=("authentik_policies.policybindingmodel", models.Model),
),
migrations.CreateModel(
name="DeviceGroup",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybindingmodel",
),
),
("name", models.TextField(unique=True)),
],
bases=("authentik_policies.policybindingmodel",),
),
migrations.CreateModel(
name="DeviceConnection",
fields=[
(
"device_connection_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
(
"connector",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints.connector",
),
),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_endpoints.device"
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="device",
name="connections",
field=models.ManyToManyField(
through="authentik_endpoints.DeviceConnection", to="authentik_endpoints.connector"
),
),
migrations.AddField(
model_name="device",
name="group",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_endpoints.devicegroup",
),
),
migrations.CreateModel(
name="DeviceUserBinding",
fields=[
(
"policybinding_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybinding",
),
),
("is_primary", models.BooleanField(default=False)),
(
"connector",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints.connector",
),
),
],
options={
"abstract": False,
},
bases=("authentik_policies.policybinding",),
),
migrations.CreateModel(
name="EndpointStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"connector",
authentik.lib.models.InheritanceForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints.connector",
),
),
],
options={
"abstract": False,
},
bases=("authentik_flows.stage",),
),
migrations.CreateModel(
name="DeviceFactSnapshot",
fields=[
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
(
"snapshot_id",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("data", models.JSONField(default=dict)),
("created", models.DateTimeField(auto_now_add=True)),
(
"connection",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_endpoints.deviceconnection",
),
),
],
options={
"abstract": False,
"indexes": [
models.Index(fields=["expires"], name="authentik_e_expires_a1e8b7_idx"),
models.Index(fields=["expiring"], name="authentik_e_expirin_b1eb6b_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_ce06ca_idx"
),
],
},
),
migrations.AddIndex(
model_name="device",
index=models.Index(fields=["expires"], name="authentik_e_expires_f533f5_idx"),
),
migrations.AddIndex(
model_name="device",
index=models.Index(fields=["expiring"], name="authentik_e_expirin_038503_idx"),
),
migrations.AddIndex(
model_name="device",
index=models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_b26ba1_idx"
),
),
]

View File

@@ -1,61 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-27 00:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_endpoints", "0001_initial"),
("authentik_endpoints_connectors_agent", "0001_initial"),
("authentik_policies", "0011_policybinding_failure_result_and_more"),
]
operations = [
migrations.RenameModel(
old_name="DeviceGroup",
new_name="DeviceAccessGroup",
),
migrations.AlterModelOptions(
name="device",
options={"verbose_name": "Device", "verbose_name_plural": "Devices"},
),
migrations.AlterModelOptions(
name="deviceaccessgroup",
options={
"verbose_name": "Device access group",
"verbose_name_plural": "Device access groups",
},
),
migrations.AlterModelOptions(
name="deviceconnection",
options={
"verbose_name": "Device connection",
"verbose_name_plural": "Device connections",
},
),
migrations.AlterModelOptions(
name="devicefactsnapshot",
options={
"verbose_name": "Device fact snapshot",
"verbose_name_plural": "Device fact snapshots",
},
),
migrations.AlterModelOptions(
name="deviceuserbinding",
options={
"verbose_name": "Device User binding",
"verbose_name_plural": "Device User bindings",
},
),
migrations.RenameField(
model_name="device",
old_name="group",
new_name="access_group",
),
migrations.AlterField(
model_name="device",
name="name",
field=models.TextField(unique=True),
),
]

View File

@@ -1,211 +0,0 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from django.core.cache import cache
from django.db import models
from django.db.models import OuterRef, Subquery
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.core.models import AttributesMixin, ExpiringModel
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.policies.models import PolicyBinding, PolicyBindingModel
from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tasks.schedules.models import ScheduledModel
if TYPE_CHECKING:
from authentik.endpoints.controller import BaseController
LOGGER = get_logger()
DEVICE_FACTS_CACHE_TIMEOUT = 3600
class Device(ExpiringModel, AttributesMixin, PolicyBindingModel):
device_uuid = models.UUIDField(default=uuid4, primary_key=True)
name = models.TextField(unique=True)
identifier = models.TextField(unique=True)
connections = models.ManyToManyField("Connector", through="DeviceConnection")
access_group = models.ForeignKey(
"DeviceAccessGroup", null=True, on_delete=models.SET_DEFAULT, default=None
)
@property
def cache_key_facts(self):
return f"goauthentik.io/endpoints/devices/{self.device_uuid}/facts"
@property
def cached_facts(self) -> "DeviceFactSnapshot":
if cached := cache.get(self.cache_key_facts):
return cached
facts = self.facts
cache.set(self.cache_key_facts, facts, timeout=DEVICE_FACTS_CACHE_TIMEOUT)
return facts
@property
def facts(self) -> "DeviceFactSnapshot":
data = {}
last_updated = datetime.fromtimestamp(0, UTC)
for snapshot_data, snapshort_created in DeviceFactSnapshot.filter_not_expired(
snapshot_id__in=Subquery(
DeviceFactSnapshot.objects.filter(
connection__connector=OuterRef("connection__connector"), connection__device=self
)
.order_by("-created")
.values("snapshot_id")[:1]
)
).values_list("data", "created"):
MERGE_LIST_UNIQUE.merge(data, snapshot_data)
last_updated = max(last_updated, snapshort_created)
return DeviceFactSnapshot(data=data, created=last_updated)
def __str__(self):
return f"Device {self.name} {self.identifier} ({self.pk})"
class Meta(ExpiringModel.Meta):
verbose_name = _("Device")
verbose_name_plural = _("Devices")
class DeviceUserBinding(PolicyBinding):
is_primary = models.BooleanField(default=False)
# Used for storing a reference to the connector if this user/group binding was created
# by a connector and not manually
connector = models.ForeignKey("Connector", on_delete=models.CASCADE, null=True)
class Meta(PolicyBinding.Meta):
verbose_name = _("Device User binding")
verbose_name_plural = _("Device User bindings")
class DeviceConnection(SerializerModel):
device_connection_uuid = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE)
connector = models.ForeignKey("Connector", on_delete=models.CASCADE)
def create_snapshot(self, data: dict[str, Any]):
expires = now() + timedelta_from_string(self.connector.snapshot_expiry)
# If this is the first snapshot for this connection, purge the cache
if not DeviceFactSnapshot.objects.filter(connection=self).exists():
LOGGER.debug("Purging facts cache for device", device=self.device)
cache.delete(self.device.cache_key_facts)
return DeviceFactSnapshot.objects.create(
connection=self,
data=data,
expiring=True,
expires=expires,
)
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.api.device_connections import DeviceConnectionSerializer
return DeviceConnectionSerializer
class Meta:
verbose_name = _("Device connection")
verbose_name_plural = _("Device connections")
class DeviceFactSnapshot(ExpiringModel, SerializerModel):
snapshot_id = models.UUIDField(primary_key=True, default=uuid4)
connection = models.ForeignKey(DeviceConnection, on_delete=models.CASCADE)
data = models.JSONField(default=dict)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Device fact snapshot {self.snapshot_id} from {self.created}"
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.api.device_fact_snapshots import DeviceFactSnapshotSerializer
return DeviceFactSnapshotSerializer
class Meta(ExpiringModel.Meta):
verbose_name = _("Device fact snapshot")
verbose_name_plural = _("Device fact snapshots")
class Connector(ScheduledModel, SerializerModel):
connector_uuid = models.UUIDField(default=uuid4, primary_key=True)
name = models.TextField()
enabled = models.BooleanField(default=True)
snapshot_expiry = models.TextField(
default="hours=24",
validators=[timedelta_string_validator],
)
objects = InheritanceManager()
@property
def stage(self) -> type[StageView] | None:
return None
@property
def component(self) -> str:
raise NotImplementedError
@property
def controller(self) -> type["BaseController[Connector]"]:
raise NotImplementedError
@property
def schedule_specs(self) -> list[ScheduleSpec]:
from authentik.endpoints.tasks import endpoints_sync
return [
ScheduleSpec(
actor=endpoints_sync,
uid=self.name,
args=(self.pk,),
crontab="3-59/15 * * * *",
send_on_save=True,
),
]
class DeviceAccessGroup(PolicyBindingModel):
name = models.TextField(unique=True)
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.api.device_access_group import DeviceAccessGroupSerializer
return DeviceAccessGroupSerializer
class Meta:
verbose_name = _("Device access group")
verbose_name_plural = _("Device access groups")
class EndpointStage(Stage):
connector = InheritanceForeignKey(Connector, on_delete=models.CASCADE)
@property
def view(self) -> type["StageView"]:
from authentik.endpoints.stage import EndpointStageView
return EndpointStageView
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.api.stages import EndpointStageSerializer
return EndpointStageSerializer
@property
def component(self) -> str:
return "ak-endpoints-stage"

View File

@@ -1,15 +0,0 @@
from authentik.endpoints.models import EndpointStage
from authentik.flows.stage import StageView
PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
class EndpointStageView(StageView):
def dispatch(self, request, *args, **kwargs):
stage: EndpointStage = self.executor.current_stage
inner_stage: type[StageView] | None = stage.connector.stage
if not inner_stage:
return self.executor.stage_ok()
view = inner_stage(self.executor, request=self.request)
return view.dispatch(request, *args, **kwargs)

View File

@@ -1,27 +0,0 @@
"""Endpoint tasks"""
from typing import Any
from django.utils.translation import gettext_lazy as _
from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.endpoints.controller import EnrollmentMethods
from authentik.endpoints.models import Connector
LOGGER = get_logger()
@actor(description=_("Sync endpoints."))
def endpoints_sync(connector_pk: Any):
connector: Connector | None = (
Connector.objects.filter(pk=connector_pk).select_subclasses().first()
)
if not connector:
return
controller = connector.controller
ctrl = controller(connector)
if EnrollmentMethods.AUTOMATIC_API not in ctrl.supported_enrollment_methods():
return
LOGGER.info("Syncing connector", connector=connector.name)
ctrl.sync_endpoints()

View File

@@ -1,79 +0,0 @@
from rest_framework.test import APITestCase
from authentik.endpoints.models import Connector, Device, DeviceConnection
from authentik.lib.generators import generate_id
class TestEndpointFacts(APITestCase):
def test_facts_cache_purge(self):
"""Test that creating a snapshot for a new connection purges the facts cache"""
device = Device.objects.create(
identifier=generate_id(),
name=generate_id(),
)
self.assertEqual(device.cached_facts.data, {})
connector = Connector.objects.create(name=generate_id())
connection = DeviceConnection.objects.create(
device=device,
connector=connector,
)
connection.create_snapshot({"vendor": {"goauthentik.io/testing": {"foo": "bar"}}})
self.assertEqual(
device.cached_facts.data, {"vendor": {"goauthentik.io/testing": {"foo": "bar"}}}
)
def test_facts_merge(self):
"""test facts merging"""
device = Device.objects.create(
identifier=generate_id(),
name=generate_id(),
)
connection_a = DeviceConnection.objects.create(
device=device,
connector=Connector.objects.create(name=generate_id()),
)
connection_a.create_snapshot(
{
"software": [
{
"name": "software-a",
"version": "1.2.3.4",
"source": "package",
}
]
}
)
connection_a = DeviceConnection.objects.create(
device=device,
connector=Connector.objects.create(name=generate_id()),
)
connection_a.create_snapshot(
{
"software": [
{
"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",
},
]
},
)

View File

@@ -1,11 +0,0 @@
from authentik.endpoints.api.connectors import ConnectorViewSet
from authentik.endpoints.api.device_access_group import DeviceAccessGroupViewSet
from authentik.endpoints.api.device_user_bindings import DeviceUserBindingViewSet
from authentik.endpoints.api.devices import DeviceViewSet
api_urlpatterns = [
("endpoints/connectors", ConnectorViewSet, "endpoint_connectors"),
("endpoints/devices", DeviceViewSet, "endpoint_device"),
("endpoints/device_bindings", DeviceUserBindingViewSet, "endpoint_device_bindings"),
("endpoints/device_access_groups", DeviceAccessGroupViewSet, "endpoint_device_access_groups"),
]

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