Compare commits

..

11 Commits

Author SHA1 Message Date
Ken Sternberg
62a407ebbc Intermediate commit. 2026-03-13 10:02:18 -07:00
Ken Sternberg
e966159692 The great disarrangement has begun. 2026-03-12 15:57:39 -07:00
Ken Sternberg
b5eab028ee Accidentally deleted this with a 'git reset'; preserving now. 2026-03-12 09:23:41 -07:00
Ken Sternberg
25d7ba59fd Merge branch 'main' into web/style/flow-css-barrel-file
* main:
  core: bump black from 26.3.0 to 26.3.1 (#20848)
  core: bump aws-cdk-lib from 2.242.0 to 2.243.0 (#20849)
  core: bump goauthentik/fips-python from `46b26b8` to `f9f8a26` in /lifecycle/container (#20851)
  core: bump library/node from 25.8.0-trixie to 25.8.1-trixie in /website (#20854)
  ci: bump actions/download-artifact from 8.0.0 to 8.0.1 (#20850)
2026-03-12 09:18:30 -07:00
Ken Sternberg
f7096a2c84 Merge branch 'main' into web/style/flow-css-barrel-file
* main:
  web/flow/stages: permit the form handler to look in the light or shadowDOM for controls (#20832)
  web/style/flow: flow css barrel file (#20833)
  web/flow: provide labels for the stage import-and-invoke table (#20834)
  core: bump goauthentik/fips-python from `3636935` to `46b26b8` in /lifecycle/container (#20842)
  core: bump library/nginx from `0236ee0` to `d0913a1` in /website (#20843)
  core, web: update translations (#20835)
  core: bump goauthentik/fips-debian from `0975985` to `2517845` in /lifecycle/container (#20841)
  core: bump twilio from 9.10.2 to 9.10.3 (#20838)
  core: bump aws-cdk-lib from 2.241.0 to 2.242.0 (#20840)
  ci: bump astral-sh/setup-uv from 7.3.1 to 7.4.0 in /.github/actions/setup (#20844)
2026-03-11 13:07:37 -07:00
Ken Sternberg
4273b15320 Missed a spot. 2026-03-10 13:59:43 -07:00
Ken Sternberg
27a550b18d Merge branch 'main' into web/style/allow-alternative-stylesheets
* main:
  providers/SCIM: Add discover support (#20658)
2026-03-10 13:48:37 -07:00
Ken Sternberg
a09cf62bac Merge branch 'main' into web/style/allow-alternative-stylesheets
* main:
  core: allow interfaces to specify alternative stylesheets (#20774)
  website/docs: update agent docs (#20782)
  core, web: update translations (#20809)
  lifecycle/aws: bump aws-cdk from 2.1109.0 to 2.1110.0 in /lifecycle/aws (#20810)
  core: bump axllent/mailpit from v1.29.2 to v1.29.3 in /tests/e2e (#20811)
  core: bump cachetools from 7.0.4 to 7.0.5 (#20812)
  core: bump goauthentik/fips-python from `b481db2` to `3636935` in /lifecycle/container (#20814)
  core: bump goauthentik/fips-debian from `6c9197b` to `0975985` in /lifecycle/container (#20815)
  web: bump the storybook group across 1 directory with 5 updates (#20816)
  web: bump cspell from 9.6.4 to 9.7.0 (#20817)
  web: bump @formatjs/intl-listformat from 8.2.1 to 8.2.2 in /web (#20818)
  web: bump mermaid from 11.12.3 to 11.13.0 in /web (#20819)
  web: bump @types/node from 25.3.5 to 25.4.0 in /web (#20820)
  endpoints/connectors/agent: cleanup leftover (#20808)
  endpoints: prevent selection of incompatible connector (#20806)
  website/docs: Add steps to set up group devices (#20735)
  web/rbac: disambiguate duplicate permission names in initial permissions (#20786)
2026-03-10 10:28:08 -07:00
Ken Sternberg
cf2ab7f701 Isolated flows to have their own CSS barrel file. 2026-03-09 14:48:38 -07:00
Ken Sternberg
cc4ce19ccc Merge branch 'main' into web/style/allow-alternative-stylesheets
* main: (23 commits)
  web: CodeSpell -> CSpell migration (#20188)
  core: bump goauthentik.io/api/v3 to 3.2026.5.0-rc1-1773052201 (#20801)
  core: bump github.com/go-openapi/runtime from 0.29.2 to 0.29.3 (#20787)
  core: bump golang.org/x/sync from 0.19.0 to 0.20.0 (#20788)
  web: bump the storybook group across 1 directory with 5 updates (#20794)
  core: bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#20789)
  core: bump goauthentik/selenium from 145.0-ak-0.40.3 to 145.0-ak-0.40.5 in /tests/e2e (#20790)
  core: bump black from 26.1.0 to 26.3.0 (#20791)
  core: bump cachetools from 7.0.3 to 7.0.4 (#20792)
  core: bump goauthentik/fips-python from `38c4dd2` to `b481db2` in /lifecycle/container (#20796)
  web: bump @rollup/plugin-commonjs from 29.0.1 to 29.0.2 in /web in the rollup group across 1 directory (#20795)
  core: bump astral-sh/uv from 0.10.8 to 0.10.9 in /lifecycle/container (#20797)
  core: bump goauthentik/fips-debian from `4966b90` to `6c9197b` in /lifecycle/container (#20798)
  web: bump @types/node from 25.3.3 to 25.3.5 in /web (#20799)
  web: bump knip from 5.85.0 to 5.86.0 in /web (#20800)
  enterprise/endpoints/connectors: add google_chrome (#19129)
  providers/oauth2: decode percent-encoded basic auth (#20779)
  web: bump immutable from 5.1.4 to 5.1.5 in /web (#20720)
  web: bump the storybook group across 1 directory with 5 updates (#20731)
  web: bump @rollup/plugin-commonjs from 29.0.0 to 29.0.1 in /web in the rollup group across 1 directory (#20732)
  ...
2026-03-09 10:50:56 -07:00
Ken Sternberg
bd304e76c8 web/core/templates: make it possible for interfaces to designate alternative stylesheets
## What

Moves the stylesheet invocation in `theme.html` to `skeleton.html`, give it a block and a block name so that pages using `skeleton.html` can override or extend it as needed.

## Why

The biggest wall we’re hitting right now is the lack of flexibility at the very top of the CSS. We simply use the same CSS file for *too much*, when really we should be thinking in terms of leaner, more targeted top-level CSS for some things, and more rich and expressive CSS when it’s necessary.

The style sheet was being loaded unconditionally in `theme.html`; it’s not in a conditional statement or overridable where it was; `skeleton` just loads it blindly. This change lets `theme.html` be what it is meant to be, an isolated container for the JavaScript logic for discerning the color mode, while enabling CSS developers to elide the stylesheet, provide alternative stylesheets, or (using `{{ block.super}}`) amend or extend the default stylesheet.
2026-03-06 14:15:11 -08:00
3527 changed files with 17846 additions and 781042 deletions

View File

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

View File

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

View File

@@ -9,5 +9,7 @@ build_docs/**
**/*Dockerfile
blueprints/local
.git
!gen-ts-api/node_modules
!gen-ts-api/dist/**
!gen-go-api/
.venv
target/

9
.gitattributes vendored
View File

@@ -1,9 +0,0 @@
packages/client-*/** linguist-generated
web/packages/lex/* linguist-vendored
web/packages/node-domexception/* linguist-vendored
web/packages/formdata-polyfill/* linguist-vendored
web/packages/sfe/vendored/* linguist-vendored
website/vendored/* linguist-vendored
website/docs/** linguist-documentation
website/integrations/** linguist-documentation
website/api/** linguist-documentation

View File

@@ -115,13 +115,20 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
REASON: ${{ steps.should_run.outputs.reason }}
run: |
set -e -o pipefail
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
# Get PR details
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
# Determine which labels to process
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
# Only process the specific label that was just added
if [ "${{ github.event_name }}" = "issues" ]; then
LABEL_NAME="${{ github.event.label.name }}"
@@ -145,13 +152,13 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
LABELS: '${{ steps.pr_details.outputs.labels }}'
run: |
set -e -o pipefail
PR_NUMBER='${{ steps.should_run.outputs.pr_number }}'
COMMIT_SHA='${{ steps.should_run.outputs.merge_commit_sha }}'
PR_TITLE='${{ steps.pr_details.outputs.pr_title }}'
PR_AUTHOR='${{ steps.pr_details.outputs.pr_author }}'
LABELS='${{ steps.pr_details.outputs.labels }}'
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
echo "Found backport labels: $LABELS"

View File

@@ -4,7 +4,7 @@ description: "Setup authentik testing environment"
inputs:
dependencies:
description: "List of dependencies to setup"
default: "system,python,rust,node,go,runtime"
default: "system,python,node,go,runtime"
postgresql_version:
description: "Optional postgresql image tag"
default: "16"
@@ -22,7 +22,7 @@ runs:
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v5
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v5
with:
enable-cache: true
- name: Setup python
@@ -34,23 +34,6 @@ runs:
if: ${{ contains(inputs.dependencies, 'python') }}
shell: bash
run: uv sync --all-extras --dev --frozen
- name: Setup rust (stable)
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
with:
rustflags: ""
- name: Setup rust (nightly)
if: ${{ contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
with:
toolchain: nightly
components: rustfmt
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@80a23c5ba9e1100fd8b777106e810018ed662a7b # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4

View File

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

3
.github/codecov.yml vendored
View File

@@ -8,6 +8,3 @@ coverage:
threshold: 1%
comment:
after_n_builds: 3
ignore:
- packages/client-rust
- packages/client-ts

View File

@@ -40,17 +40,6 @@ updates:
#region Rust
- package-ecosystem: cargo
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "core:"
labels:
- dependencies
- package-ecosystem: rust-toolchain
directory: "/"
schedule:
@@ -272,10 +261,8 @@ updates:
- dependencies
- package-ecosystem: docker-compose
directories:
- /packages/client-go
- /packages/client-rust
- /packages/client-ts
# - /scripts # Maybe
- /scripts/api
- /tests/e2e
schedule:
interval: daily

View File

@@ -26,7 +26,7 @@ REPLACE ME
If an API change has been made
- [ ] The API schema and clients have been updated (`make gen`)
- [ ] The API schema has been updated (`make gen-build`)
If changes to the frontend have been made

View File

@@ -75,6 +75,10 @@ jobs:
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: Build Docker Image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
id: push

66
.github/workflows/api-ts-publish.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
---
name: API - Publish Typescript client
on:
push:
branches: [main]
paths:
- "schema.yml"
workflow_dispatch:
permissions:
# Required for NPM OIDC trusted publisher
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"
- name: Generate API Client
run: make gen-client-ts
- name: Publish package
working-directory: gen-ts-api/
run: |
npm i
npm publish --tag generated
- name: Upgrade /web
working-directory: web
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-web-api-client
commit-message: "web: bump API Client version"
title: "web: bump API Client version"
body: "web: bump API Client version"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@@ -41,7 +41,7 @@ jobs:
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with:
path: |
${{ github.workspace }}/website/api/.docusaurus

View File

@@ -28,52 +28,30 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- job: bandit
deps: python
- job: black
deps: python
- job: spellcheck
deps: node
- job: pending-migrations
deps: python,runtime
- job: ruff
deps: python
- job: mypy
deps: python
- job: cargo-deny
deps: rust
- job: cargo-machete
deps: rust
- job: clippy
deps: rust
- job: rustfmt
deps: rust-nightly
job:
- bandit
- black
- spellcheck
- pending-migrations
- ruff
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: ${{ matrix.deps }}
- name: run job
run: make ci-lint-${{ matrix.job }}
test-gen:
run: uv run make ci-${{ matrix.job }}
test-gen-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: "system,python,go,node,runtime,rust-nightly"
- name: generate schema
run: make migrate gen-build
- name: generate API clients
run: make gen-clients
- name: ensure schema is up-to-date
run: git diff --exit-code -- schema.yml blueprints/schema.json packages/client-go packages/client-rust packages/client-ts
run: git diff --exit-code -- schema.yml blueprints/schema.json
test-migrations:
runs-on: ubuntu-latest
steps:
@@ -211,42 +189,33 @@ jobs:
job:
- name: proxy
glob: tests/e2e/test_provider_proxy*
profiles: selenium
- name: oauth
glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
profiles: selenium
- name: oauth-oidc
glob: tests/e2e/test_provider_oidc*
profiles: selenium
- name: saml
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
profiles: selenium
- name: ldap
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: ws-fed
glob: tests/e2e/test_provider_ws_fed*
profiles: selenium
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
profiles: selenium
- name: endpoints
glob: tests/e2e/test_endpoints_*
profiles: selenium
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env
env:
COMPOSE_PROFILES: ${{ matrix.job.profiles }}
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -255,6 +224,7 @@ jobs:
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run e2e
@@ -282,15 +252,13 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
env:
COMPOSE_PROFILES: selenium
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -299,6 +267,7 @@ jobs:
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
@@ -314,34 +283,11 @@ jobs:
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/
test-rust:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: rust
- name: run tests
run: |
cargo llvm-cov --no-report nextest --workspace
cargo llvm-cov report --codecov --output-path target/llvm-cov-target/rust.json
- uses: ./.github/actions/test-results
if: ${{ always() }}
with:
files: target/llvm-cov-target/rust.json
flags: rust
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-rust
path: target/llvm-cov-target/rust.json
ci-core-mark:
if: always()
needs:
- lint
- test-gen
- test-gen-build
- test-migrations
- test-migrations-from-stable
- test-unittest

View File

@@ -31,6 +31,8 @@ jobs:
mkdir -p web/dist
mkdir -p website/help
touch web/dist/test website/help/test
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
with:
@@ -46,6 +48,8 @@ jobs:
go-version-file: "go.mod"
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
run: make gen-client-go
- name: prepare database
run: |
uv run make migrate
@@ -103,6 +107,8 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate API
run: make gen-client-go
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
@@ -150,6 +156,8 @@ jobs:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Generate API
run: make gen-client-go
- name: Build web
working-directory: web/
run: |

View File

@@ -40,6 +40,8 @@ jobs:
- working-directory: ${{ matrix.project }}/
run: |
npm ci
- name: Generate API
run: make gen-client-ts
- name: Lint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
@@ -54,6 +56,8 @@ jobs:
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: build
working-directory: web/
run: npm run build
@@ -80,6 +84,8 @@ jobs:
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: test
working-directory: web/
run: npm run test || exit 0

View File

@@ -29,16 +29,16 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
id: compress
uses: calibreapp/image-actions@03c976c29803442fc4040a9de5509669e7759b81 # main
uses: calibreapp/image-actions@d9c8ee5c3dc52ae4622c82ead88d658f4b16b65f # main
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}

View File

@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}

View File

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

View File

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

View File

@@ -29,10 +29,10 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -57,10 +57,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:

View File

@@ -103,6 +103,10 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: Docker Login Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
@@ -160,6 +164,10 @@ jobs:
working-directory: web/
run: |
npm ci
- name: Generate API Clients
run: |
make gen-client-ts
make gen-client-go
- name: Build web
working-directory: web/
run: |
@@ -172,7 +180,7 @@ jobs:
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}

View File

@@ -67,10 +67,10 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- id: get-user-id
name: Get GitHub app user ID
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
@@ -96,7 +96,7 @@ jobs:
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
git push --follow-tags
- name: Create Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
token: "${{ steps.app-token.outputs.token }}"
tag_name: "version/${{ inputs.version }}"
@@ -115,10 +115,10 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
repositories: helm
- id: get-user-id
name: Get GitHub app user ID
@@ -157,10 +157,10 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
repositories: version
- id: get-user-id
name: Get GitHub app user ID

View File

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

View File

@@ -21,10 +21,10 @@ jobs:
steps:
- id: generate_token
if: ${{ github.event_name != 'pull_request' }}
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
if: ${{ github.event_name != 'pull_request' }}
with:
@@ -33,6 +33,8 @@ jobs:
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
run: make gen-client-ts
- name: run extract
run: |
uv run make i18n-extract

19
.gitignore vendored
View File

@@ -195,24 +195,6 @@ pyvenv.cfg
pip-selfcheck.json
# End of https://www.gitignore.io/api/python,django
# Created by https://www.toptal.com/developers/gitignore/api/rust
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# End of https://www.toptal.com/developers/gitignore/api/rust
/static/
local.env.yml
@@ -220,6 +202,7 @@ media/
*mmdb
.idea/
/gen-*/
data/
# Local Netlify folder

View File

@@ -1,7 +1,6 @@
# Prettier Ignorefile
## Static Files
CODEOWNERS
**/LICENSE
authentik/stages/**/*

View File

@@ -17,6 +17,6 @@
"ms-python.vscode-pylance",
"redhat.vscode-yaml",
"Tobermory.es6-string-html",
"unifiedjs.vscode-mdx"
"unifiedjs.vscode-mdx",
]
}

18
.vscode/settings.json vendored
View File

@@ -38,10 +38,10 @@
"!AtIndex scalar",
"!ParseJSON scalar"
],
"js/ts.preferences.importModuleSpecifier": "non-relative",
"js/ts.preferences.importModuleSpecifierEnding": "index",
"js/ts.tsdk.path": "./node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml"
},
@@ -57,13 +57,5 @@
"go.testEnvVars": {
"WORKSPACE_DIR": "${workspaceFolder}"
},
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"],
"search.exclude": {
"**/*.code-search": true,
"**/bower_components": true,
"**/node_modules": true,
"**/playwright-report/**": true,
"**/website/**/build": true,
"**/client-*": true
}
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
}

View File

@@ -3,7 +3,6 @@
# Backend
authentik/ @goauthentik/backend
blueprints/ @goauthentik/backend
src/ @goauthentik/backend
cmd/ @goauthentik/backend
internal/ @goauthentik/backend
lifecycle/ @goauthentik/backend
@@ -12,12 +11,8 @@ scripts/ @goauthentik/backend
tests/ @goauthentik/backend
pyproject.toml @goauthentik/backend
uv.lock @goauthentik/backend
Cargo.toml @goauthentik/backend
Cargo.lock @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
.cargo/ @goauthentik/backend
rust-toolchain.toml @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
@@ -27,17 +22,14 @@ Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Backend packages
packages/client-rust @goauthentik/backend
packages/django-channels-postgres @goauthentik/backend
packages/django-postgres-cache @goauthentik/backend
packages/django-dramatiq-postgres @goauthentik/backend
# Web packages
tsconfig.json @goauthentik/frontend
package.json @goauthentik/frontend
package-lock.json @goauthentik/frontend
packages/package.json @goauthentik/frontend
packages/package-lock.json @goauthentik/frontend
packages/client-ts @goauthentik/frontend
packages/docusaurus-config @goauthentik/frontend
packages/esbuild-plugin-live-reload @goauthentik/frontend
packages/eslint-config @goauthentik/frontend

2639
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

101
Makefile
View File

@@ -15,11 +15,14 @@ else
SED_INPLACE = sed -i
endif
GEN_API_TS = gen-ts-api
GEN_API_PY = gen-py-api
GEN_API_GO = gen-go-api
BREW_LDFLAGS :=
BREW_CPPFLAGS :=
BREW_PKG_CONFIG_PATH :=
CARGO := cargo
UV := uv
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
@@ -66,28 +69,22 @@ help: ## Show this help
sort
@echo ""
go-test: ## Run the golang tests
go-test:
go test -timeout 0 -v -race -cover ./...
rust-test: ## Run the Rust tests
$(CARGO) nextest run --workspace
test: ## Run the server tests and produce a coverage report (locally)
$(UV) run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
$(UV) run coverage html
$(UV) run coverage report
lint-fix-rust:
$(CARGO) +nightly fmt --all -- --config-path "${PWD}/.cargo/rustfmt.toml"
lint-fix: lint-fix-rust ## Lint and automatically fix errors in the python source code. Reports spelling errors.
lint-fix: lint-spellcheck ## Lint and automatically fix errors in the python source code. Reports spelling errors.
$(UV) run black $(PY_SOURCES)
$(UV) run ruff check --fix $(PY_SOURCES)
lint-spellcheck: ## Reports spelling errors.
npm run lint:spellcheck
lint: ci-lint-bandit ci-lint-mypy ci-lint-cargo-deny ci-lint-cargo-machete ## Lint the python and golang sources
lint: ci-bandit ci-mypy ## Lint the python and golang sources
golangci-lint run -v
core-install:
@@ -120,7 +117,8 @@ core-i18n-extract:
--no-obsolete \
--ignore web \
--ignore internal \
--ignore packages/client-ts \
--ignore ${GEN_API_TS} \
--ignore ${GEN_API_GO} \
--ignore website \
-l en
@@ -186,7 +184,7 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
gen-diff: ## (Release) generate the changelog diff between the current schema and the last version
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
git show ${last_version}:schema.yml > schema-old.yml
docker compose -f scripts/compose.yml run --rm --user "${UID}:${GID}" diff \
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
/local/schema-old.yml \
@@ -196,26 +194,51 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
$(SED_INPLACE) 's/}/&#125;/g' diff.md
npx prettier --write diff.md
gen-client-go: ## Build and install the authentik API for Golang
make -C "${PWD}/packages/client-go" build
gen-clean-ts: ## Remove generated API client for TypeScript
rm -rf ${PWD}/${GEN_API_TS}/
rm -rf ${PWD}/web/node_modules/@goauthentik/api/
gen-client-rust: ## Build and install the authentik API for Rust
make -C "${PWD}/packages/client-rust" build version=${NPM_VERSION}
make lint-fix-rust
gen-clean-py: ## Remove generated API client for Python
rm -rf ${PWD}/${GEN_API_PY}
gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application
make -C "${PWD}/packages/client-ts" build
npm --prefix web install
gen-clean-go: ## Remove generated API client for Go
rm -rf ${PWD}/${GEN_API_GO}
_gen-clients: gen-client-go gen-client-rust gen-client-ts
gen-clients: ## Build and install API clients used by authentik
$(MAKE) _gen-clients -j
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen: gen-build gen-clients ## Build and install API schema and clients used by authentik
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/${GEN_API_TS} \
-c /local/scripts/api/ts-config.yaml \
--additional-properties=npmVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
cd ${PWD}/${GEN_API_TS} && npm i
cd ${PWD}/${GEN_API_TS} && npm link
cd ${PWD}/web && npm link @goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
mkdir -p ${PWD}/${GEN_API_PY}
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ${PWD}/${GEN_API_GO}
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
gen-dev-config: ## Generate a local development config file
$(UV) run scripts/generate_config.py
gen: gen-build gen-client-ts
#########################
## Node.js
#########################
@@ -295,6 +318,7 @@ docs-api-clean: ## Clean generated API documentation
#########################
docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
test-docker:
@@ -307,40 +331,27 @@ test-docker:
# which makes the YAML File a lot smaller
ci--meta-debug:
$(UV) run python -V || echo "No python installed"
$(CARGO) --version || echo "No rust installed"
node --version || echo "No node installed"
$(UV) run python -V
node --version
ci-lint-mypy: ci--meta-debug
ci-mypy: ci--meta-debug
$(UV) run mypy --strict $(PY_SOURCES)
ci-lint-black: ci--meta-debug
ci-black: ci--meta-debug
$(UV) run black --check $(PY_SOURCES)
ci-lint-ruff: ci--meta-debug
ci-ruff: ci--meta-debug
$(UV) run ruff check $(PY_SOURCES)
ci-lint-spellcheck: ci--meta-debug
ci-spellcheck: ci--meta-debug
npm run lint:spellcheck
ci-lint-bandit: ci--meta-debug
ci-bandit: ci--meta-debug
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii
ci-lint-pending-migrations: ci--meta-debug
ci-pending-migrations: ci--meta-debug
$(UV) run ak makemigrations --check
ci-lint-cargo-deny: ci--meta-debug
$(CARGO) deny --locked --workspace check --config "${PWD}/.cargo/deny.toml"
ci-lint-cargo-machete: ci--meta-debug
$(CARGO) machete
ci-lint-rustfmt: ci--meta-debug
$(CARGO) +nightly fmt --all --check -- --config-path "${PWD}/.cargo/rustfmt.toml"
ci-lint-clippy: ci--meta-debug
$(CARGO) clippy --workspace -- -D warnings
ci-test: ci--meta-debug
$(UV) run coverage run manage.py test --keepdb authentik
$(UV) run coverage report

View File

@@ -8,8 +8,8 @@ from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.api import Models
from authentik.lib.utils.reflection import get_apps
from authentik.policies.event_matcher.models import model_choices
class AppSerializer(PassiveSerializer):
@@ -42,6 +42,6 @@ class ModelViewSet(ViewSet):
def list(self, request: Request) -> Response:
"""Read-only view list all installed models"""
data = []
for name, label in Models.choices:
for name, label in model_choices():
data.append({"name": name, "label": label})
return Response(AppSerializer(data, many=True).data)

View File

@@ -106,14 +106,14 @@ class TokenAuthentication(BaseAuthentication):
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.objects.filter(
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user, key_token
# then try to auth via JWT
jwt_token = AccessToken.objects.filter(
jwt_token = AccessToken.filter_not_expired(
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:

View File

@@ -4,7 +4,7 @@ from drf_spectacular.plumbing import build_object_type
from rest_framework import pagination
from rest_framework.response import Response
from authentik.api.v3.schema.pagination import PAGINATION
from authentik.api.v3.schema.response import PAGINATION
class Pagination(pagination.PageNumberPagination):

103
authentik/api/schema.py Normal file
View File

@@ -0,0 +1,103 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
from collections.abc import Callable
from typing import Any
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import ResolvedComponent
from drf_spectacular.renderers import OpenApiJsonRenderer
from drf_spectacular.settings import spectacular_settings
from structlog.stdlib import get_logger
from authentik.api.apps import AuthentikAPIConfig
from authentik.api.v3.schema.query import QUERY_PARAMS
from authentik.api.v3.schema.response import (
GENERIC_ERROR,
GENERIC_ERROR_RESPONSE,
PAGINATION,
VALIDATION_ERROR,
VALIDATION_ERROR_RESPONSE,
)
LOGGER = get_logger()
def preprocess_schema_exclude_non_api(endpoints: list[tuple[str, Any, Any, Callable]], **kwargs):
"""Filter out all API Views which are not mounted under /api"""
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if path.startswith("/" + AuthentikAPIConfig.mountpoint)
]
def postprocess_schema_register(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Register custom schema components"""
LOGGER.debug("Registering custom schemas")
generator.registry.register_on_missing(PAGINATION)
generator.registry.register_on_missing(GENERIC_ERROR)
generator.registry.register_on_missing(GENERIC_ERROR_RESPONSE)
generator.registry.register_on_missing(VALIDATION_ERROR)
generator.registry.register_on_missing(VALIDATION_ERROR_RESPONSE)
for query in QUERY_PARAMS.values():
generator.registry.register_on_missing(query)
return result
def postprocess_schema_responses(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Default error responses"""
LOGGER.debug("Adding default error responses")
for path in result["paths"].values():
for method in path.values():
method["responses"].setdefault("400", VALIDATION_ERROR_RESPONSE.ref)
method["responses"].setdefault("403", GENERIC_ERROR_RESPONSE.ref)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
# This is a workaround for authentik/stages/prompt/stage.py
# since the serializer PromptChallengeResponse
# accepts dynamic keys
for component in result["components"]["schemas"]:
if component == "PromptChallengeResponseRequest":
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
def postprocess_schema_query_params(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Optimize pagination parameters, instead of redeclaring parameters for each endpoint
declare them globally and refer to them"""
LOGGER.debug("Deduplicating query parameters")
for path in result["paths"].values():
for method in path.values():
for idx, param in enumerate(method.get("parameters", [])):
if param["name"] not in QUERY_PARAMS:
continue
method["parameters"][idx] = QUERY_PARAMS[param["name"]].ref
return result
def postprocess_schema_remove_unused(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Remove unused components"""
# To check if the schema is used, render it to JSON and then substring check that
# less efficient than walking through the tree but a lot simpler and no
# possibility that we miss something
raw = OpenApiJsonRenderer().render(result, renderer_context={}).decode()
count = 0
for key in result["components"][ResolvedComponent.SCHEMA].keys():
schema_usages = raw.count(f"#/components/{ResolvedComponent.SCHEMA}/{key}")
if schema_usages >= 1:
continue
del generator.registry[(key, ResolvedComponent.SCHEMA)]
count += 1
LOGGER.debug("Removing unused components", count=count)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
return result

View File

@@ -1,75 +0,0 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
from collections.abc import Callable
from typing import Any
from drf_spectacular.contrib.django_filters import (
DjangoFilterExtension as BaseDjangoFilterExtension,
)
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import (
ResolvedComponent,
follow_field_source,
)
from drf_spectacular.renderers import OpenApiJsonRenderer
from drf_spectacular.settings import spectacular_settings
from structlog.stdlib import get_logger
from authentik.api.apps import AuthentikAPIConfig
LOGGER = get_logger()
def preprocess_schema_exclude_non_api(endpoints: list[tuple[str, Any, Any, Callable]], **kwargs):
"""Filter out all API Views which are not mounted under /api"""
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if path.startswith("/" + AuthentikAPIConfig.mountpoint)
]
def postprocess_schema_remove_unused(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Remove unused components"""
# To check if the schema is used, render it to JSON and then substring check that
# less efficient than walking through the tree but a lot simpler and no
# possibility that we miss something
raw = OpenApiJsonRenderer().render(result, renderer_context={}).decode()
count = 0
for key in result["components"][ResolvedComponent.SCHEMA].keys():
schema_usages = raw.count(f"#/components/{ResolvedComponent.SCHEMA}/{key}")
if schema_usages >= 1:
continue
del generator.registry[(key, ResolvedComponent.SCHEMA)]
count += 1
LOGGER.debug("Removing unused components", count=count)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
return result
class DjangoFilterExtension(BaseDjangoFilterExtension):
"""
From https://github.com/netbox-community/netbox/pull/21521:
Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
_get_model_field() incorrectly double-appends to_field_name when field_name already ends
with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.
See: https://github.com/netbox-community/netbox/issues/20787
https://github.com/tfranzel/drf-spectacular/issues/1475
"""
priority = 1
def _get_model_field(self, filter_field, model):
if not filter_field.field_name:
return None
path = filter_field.field_name.split("__")
to_field_name = filter_field.extra.get("to_field_name")
if to_field_name is not None and path[-1] != to_field_name:
path.append(to_field_name)
return follow_field_source(model, path, emit_warnings=False)

View File

@@ -1,287 +0,0 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
import functools
import inspect
import re
from collections import defaultdict
from enum import Enum
from django.db.models import Choices
from django.utils.translation import get_language
from drf_spectacular.drainage import error, warn
from drf_spectacular.hooks import postprocess_schema_enum_id_removal
from drf_spectacular.plumbing import (
ResolvedComponent,
deep_import_string,
list_hash,
safe_ref,
)
from drf_spectacular.settings import spectacular_settings
from inflection import camelize
from structlog.stdlib import get_logger
LOGGER = get_logger()
# See https://github.com/tfranzel/drf-spectacular/blob/master/drf_spectacular/hooks.py
# and https://github.com/tfranzel/drf-spectacular/issues/520
def postprocess_schema_enums(result, generator, **kwargs): # noqa: PLR0912, PLR0915
"""
simple replacement of Enum/Choices that globally share the same name and have
the same choices. Aids client generation to not generate a separate enum for
every occurrence. only takes effect when replacement is guaranteed to be correct.
"""
def is_enum_prop(prop_schema):
return (
"enum" in prop_schema
or prop_schema.get("type") == "array"
and "enum" in prop_schema.get("items", {})
)
def iter_field_schemas():
def iter_prop_containers(schema, component_name=None):
if not component_name:
for _component_name, _schema in schema.items():
if spectacular_settings.COMPONENT_SPLIT_PATCH:
_component_name = re.sub("^Patched(.+)", r"\1", _component_name)
if spectacular_settings.COMPONENT_SPLIT_REQUEST:
_component_name = re.sub("(.+)Request$", r"\1", _component_name)
yield from iter_prop_containers(_schema, _component_name)
elif isinstance(schema, list):
for item in schema:
yield from iter_prop_containers(item, component_name)
elif isinstance(schema, dict):
if schema.get("properties"):
yield component_name, schema["properties"]
yield from iter_prop_containers(schema.get("oneOf", []), component_name)
yield from iter_prop_containers(schema.get("allOf", []), component_name)
yield from iter_prop_containers(schema.get("anyOf", []), component_name)
def iter_path_parameters():
for path in result.get("paths", {}).values():
for operation in path.values():
for parameter in operation.get("parameters", []):
parameter_schema = parameter.get("schema", {})
if is_enum_prop(parameter_schema):
# Move description into enum schema
if "description" in parameter:
parameter_schema["description"] = parameter.pop("description")
if "name" not in parameter:
continue
yield "", {parameter["name"]: parameter_schema}
component_schemas = result.get("components", {}).get("schemas", {})
yield from iter_prop_containers(component_schemas)
yield from iter_path_parameters()
def create_enum_component(name, schema):
component = ResolvedComponent(
name=name,
type=ResolvedComponent.SCHEMA,
schema=schema,
object=name,
)
generator.registry.register_on_missing(component)
return component
def extract_hash(schema):
if "x-spec-enum-id" in schema:
# try to use the injected enum hash first as it generated from (name, value) tuples,
# which prevents collisions on choice sets only differing in labels not values.
return schema["x-spec-enum-id"]
else:
# fall back to actual list hashing when we encounter enums not generated by us.
# remove blank/null entry for hashing. will be reconstructed in the last step
return list_hash([(i, i) for i in schema["enum"] if i not in ("", None)])
overrides = load_enum_name_overrides()
prop_hash_mapping = defaultdict(set)
hash_name_mapping = defaultdict(set)
# collect all enums, their names and choice sets
for component_name, props in iter_field_schemas():
for prop_name, prop_schema in props.items():
_prop_schema = prop_schema
if prop_schema.get("type") == "array":
_prop_schema = prop_schema.get("items", {})
if "enum" not in _prop_schema:
continue
prop_enum_cleaned_hash = extract_hash(_prop_schema)
prop_hash_mapping[prop_name].add(prop_enum_cleaned_hash)
hash_name_mapping[prop_enum_cleaned_hash].add((component_name, prop_name))
# get the suffix to be used for enums from settings
enum_suffix = spectacular_settings.ENUM_SUFFIX
# traverse all enum properties and generate a name for the choice set. naming collisions
# are resolved and a warning is emitted. giving a choice set multiple names is technically
# correct but potentially unwanted. also emit a warning there to make the user aware.
enum_name_mapping = {}
for prop_name, prop_hash_set in prop_hash_mapping.items():
for prop_hash in prop_hash_set:
if prop_hash in overrides:
enum_name = overrides[prop_hash]
elif len(prop_hash_set) == 1:
# prop_name has been used exclusively for one choice set (best case)
enum_name = f"{camelize(prop_name)}{enum_suffix}"
elif len(hash_name_mapping[prop_hash]) == 1:
# prop_name has multiple choice sets, but each one limited to one component only
component_name, _ = next(iter(hash_name_mapping[prop_hash]))
enum_name = f"{camelize(component_name)}{camelize(prop_name)}{enum_suffix}"
else:
enum_name = f"{camelize(prop_name)}{prop_hash[:3].capitalize()}{enum_suffix}"
warn(
f"enum naming encountered a non-optimally resolvable collision for fields "
f'named "{prop_name}". The same name has been used for multiple choice sets '
f'in multiple components. The collision was resolved with "{enum_name}". '
f"add an entry to ENUM_NAME_OVERRIDES to fix the naming."
)
if enum_name_mapping.get(prop_hash, enum_name) != enum_name:
warn(
f"encountered multiple names for the same choice set ({enum_name}). This "
f"may be unwanted even though the generated schema is technically correct. "
f"Add an entry to ENUM_NAME_OVERRIDES to fix the naming."
)
del enum_name_mapping[prop_hash]
else:
enum_name_mapping[prop_hash] = enum_name
enum_name_mapping[(prop_hash, prop_name)] = enum_name
# replace all enum occurrences with a enum schema component. cut out the
# enum, replace it with a reference and add a corresponding component.
for _, props in iter_field_schemas():
for prop_name, _prop_schema in props.items():
prop_schema = _prop_schema
is_array = prop_schema.get("type") == "array"
if is_array:
prop_schema = prop_schema.get("items", {})
if "enum" not in prop_schema:
continue
prop_enum_original_list = prop_schema["enum"]
prop_schema["enum"] = [i for i in prop_schema["enum"] if i not in ["", None]]
prop_hash = extract_hash(prop_schema)
# when choice sets are reused under multiple names, the generated name cannot be
# resolved from the hash alone. fall back to prop_name and hash for resolution.
enum_name = enum_name_mapping.get(prop_hash) or enum_name_mapping[prop_hash, prop_name]
# split property into remaining property and enum component parts
enum_schema = {k: v for k, v in prop_schema.items() if k in ["type", "enum"]}
prop_schema = {
k: v for k, v in prop_schema.items() if k not in ["type", "enum", "x-spec-enum-id"]
}
# separate actual description from name-value tuples
if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
if prop_schema.get("description", "").startswith("*"):
enum_schema["description"] = prop_schema.pop("description")
elif "\n\n*" in prop_schema.get("description", ""):
_, _, post = prop_schema["description"].partition("\n\n*")
enum_schema["description"] = "*" + post
components = [create_enum_component(enum_name, schema=enum_schema)]
if spectacular_settings.ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE:
if "" in prop_enum_original_list:
components.append(
create_enum_component(f"Blank{enum_suffix}", schema={"enum": [""]})
)
if None in prop_enum_original_list:
if spectacular_settings.OAS_VERSION.startswith("3.1"):
components.append(
create_enum_component(f"Null{enum_suffix}", schema={"type": "null"})
)
else:
components.append(
create_enum_component(f"Null{enum_suffix}", schema={"enum": [None]})
)
# undo OAS 3.1 type list NULL construction as we cover
# this in a separate component already
if spectacular_settings.OAS_VERSION.startswith("3.1") and isinstance(
enum_schema["type"], list
):
enum_schema["type"] = [t for t in enum_schema["type"] if t != "null"][0]
if len(components) == 1:
prop_schema.update(components[0].ref)
else:
prop_schema.update({"oneOf": [c.ref for c in components]})
patch_target = props[prop_name] # noqa: PLR1733
if is_array:
patch_target = patch_target["items"]
# Replace existing schema information with reference
patch_target.clear()
patch_target.update(safe_ref(prop_schema))
# sort again with additional components
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
# remove remaining ids that were not part of this hook (operation parameters mainly)
postprocess_schema_enum_id_removal(result, generator)
return result
# Fixed version of `load_enum_name_overrides()` with a LRU cache based on language
# *and* enum overrides.
# Without this, API generation breaks if there is more than 1 API present (such as in split APIs)
# Original source: drf-spectacular/drf_spectacular/plumbing.py
def load_enum_name_overrides():
cache_key = get_language() or ""
for k, v in sorted(spectacular_settings.ENUM_NAME_OVERRIDES.items()):
cache_key += f";{k}:{v}"
return _load_enum_name_overrides(cache_key)
# Original source: drf-spectacular/drf_spectacular/plumbing.py
# Only change: cache_key argument instead of language.
@functools.lru_cache
def _load_enum_name_overrides(cache_key):
overrides = {}
for name, _choices in spectacular_settings.ENUM_NAME_OVERRIDES.items():
choices = _choices
if isinstance(choices, str):
choices = deep_import_string(choices)
if not choices:
warn(
f"unable to load choice override for {name} from ENUM_NAME_OVERRIDES. "
f"please check module path string."
)
continue
if inspect.isclass(choices) and issubclass(choices, Choices):
choices = choices.choices
if inspect.isclass(choices) and issubclass(choices, Enum):
choices = [(c.value, c.name) for c in choices]
normalized_choices = []
for choice in choices:
# Allow None values in the simple values list case
if isinstance(choice, str) or choice is None:
# TODO warning
normalized_choices.append((choice, choice)) # simple choice list
elif isinstance(choice[1], (list, tuple)):
normalized_choices.extend(choice[1]) # categorized nested choices
else:
normalized_choices.append(choice) # normal 2-tuple form
# Get all of choice values that should be used in the hash, blank and
# None values get excluded in the post-processing hook for enum overrides,
# so we do the same here to ensure the hashes match
hashable_values = [
(value, label) for value, label in normalized_choices if value not in ["", None]
]
overrides[list_hash(hashable_values)] = name
if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides):
error(
"ENUM_NAME_OVERRIDES has duplication issues. Encountered multiple names "
"for the same choice set. Enum naming might be unexpected."
)
return overrides

View File

@@ -1,32 +0,0 @@
from drf_spectacular.plumbing import (
ResolvedComponent,
build_basic_type,
build_object_type,
)
from drf_spectacular.types import OpenApiTypes
PAGINATION = ResolvedComponent(
name="Pagination",
type=ResolvedComponent.SCHEMA,
object="Pagination",
schema=build_object_type(
properties={
"next": build_basic_type(OpenApiTypes.NUMBER),
"previous": build_basic_type(OpenApiTypes.NUMBER),
"count": build_basic_type(OpenApiTypes.NUMBER),
"current": build_basic_type(OpenApiTypes.NUMBER),
"total_pages": build_basic_type(OpenApiTypes.NUMBER),
"start_index": build_basic_type(OpenApiTypes.NUMBER),
"end_index": build_basic_type(OpenApiTypes.NUMBER),
},
required=[
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
),
)

View File

@@ -1,17 +1,10 @@
from typing import Any
from django.utils.translation import gettext_lazy as _
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import (
ResolvedComponent,
build_basic_type,
build_parameter_type,
)
from drf_spectacular.types import OpenApiTypes
from structlog.stdlib import get_logger
LOGGER = get_logger()
QUERY_PARAMS = {
"ordering": ResolvedComponent(
@@ -70,18 +63,3 @@ QUERY_PARAMS = {
),
),
}
def postprocess_schema_query_params(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Optimize pagination parameters, instead of redeclaring parameters for each endpoint
declare them globally and refer to them"""
LOGGER.debug("Deduplicating query parameters")
for path in result["paths"].values():
for method in path.values():
for idx, param in enumerate(method.get("parameters", [])):
if param["name"] not in QUERY_PARAMS:
continue
method["parameters"][idx] = QUERY_PARAMS[param["name"]].ref
return result

View File

@@ -1,22 +1,12 @@
from typing import Any
from django.utils.translation import gettext_lazy as _
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import (
ResolvedComponent,
build_array_type,
build_basic_type,
build_object_type,
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
from structlog.stdlib import get_logger
from authentik.api.v3.schema.pagination import PAGINATION
from authentik.api.v3.schema.query import QUERY_PARAMS
LOGGER = get_logger()
GENERIC_ERROR = ResolvedComponent(
name="GenericError",
@@ -67,40 +57,28 @@ VALIDATION_ERROR_RESPONSE = ResolvedComponent(
"description": "",
},
)
def postprocess_schema_register(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Register custom schema components"""
LOGGER.debug("Registering custom schemas")
generator.registry.register_on_missing(PAGINATION)
generator.registry.register_on_missing(GENERIC_ERROR)
generator.registry.register_on_missing(GENERIC_ERROR_RESPONSE)
generator.registry.register_on_missing(VALIDATION_ERROR)
generator.registry.register_on_missing(VALIDATION_ERROR_RESPONSE)
for query in QUERY_PARAMS.values():
generator.registry.register_on_missing(query)
return result
def postprocess_schema_responses(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Default error responses"""
LOGGER.debug("Adding default error responses")
for path in result["paths"].values():
for method in path.values():
method["responses"].setdefault("400", VALIDATION_ERROR_RESPONSE.ref)
method["responses"].setdefault("403", GENERIC_ERROR_RESPONSE.ref)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
# This is a workaround for authentik/stages/prompt/stage.py
# since the serializer PromptChallengeResponse
# accepts dynamic keys
for component in result["components"]["schemas"]:
if component == "PromptChallengeResponseRequest":
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
PAGINATION = ResolvedComponent(
name="Pagination",
type=ResolvedComponent.SCHEMA,
object="Pagination",
schema=build_object_type(
properties={
"next": build_basic_type(OpenApiTypes.NUMBER),
"previous": build_basic_type(OpenApiTypes.NUMBER),
"count": build_basic_type(OpenApiTypes.NUMBER),
"current": build_basic_type(OpenApiTypes.NUMBER),
"total_pages": build_basic_type(OpenApiTypes.NUMBER),
"start_index": build_basic_type(OpenApiTypes.NUMBER),
"end_index": build_basic_type(OpenApiTypes.NUMBER),
},
required=[
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
),
)

View File

@@ -25,7 +25,6 @@ from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
from authentik.core.apps import AppAccessWithoutBindings
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.policies.api.exec import PolicyTestResultSerializer
@@ -164,7 +163,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
request.user = user
for application in paginated_apps:
engine = PolicyEngine(application, request.user, request)
engine.empty_result = AppAccessWithoutBindings.get()
engine.build()
if engine.passing:
applications.append(application)
@@ -222,7 +220,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if not for_user:
raise ValidationError({"for_user": "User not found"})
engine = PolicyEngine(application, for_user, request)
engine.empty_result = AppAccessWithoutBindings.get()
engine.use_cache = False
with capture_logs() as logs:
engine.build()

View File

@@ -124,7 +124,7 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
"""Token Viewset"""
lookup_field = "identifier"
queryset = Token.objects.including_expired().all()
queryset = Token.objects.all()
serializer_class = TokenSerializer
search_fields = [
"identifier",

View File

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

View File

@@ -81,7 +81,7 @@ class TokenBackend(InbuiltBackend):
User().set_password(password, request=request)
return None
tokens = Token.objects.filter(
tokens = Token.filter_not_expired(
user=user, key=password, intent=TokenIntents.INTENT_APP_PASSWORD
)
if not tokens.exists():

View File

@@ -2,7 +2,7 @@
import re
import traceback
from datetime import datetime
from datetime import datetime, timedelta
from enum import StrEnum
from hashlib import sha256
from typing import Any, Self
@@ -16,7 +16,7 @@ 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 Manager, Q, QuerySet, options
from django.db.models import Q, QuerySet, options
from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -45,7 +45,6 @@ from authentik.lib.models import (
SerializerModel,
)
from authentik.lib.utils.inheritance import get_deepest_child
from authentik.lib.utils.reflection import class_to_path
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.rbac.models import Role
@@ -518,7 +517,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
@property
def ak_groups(self):
"""This is a proxy for a renamed, deprecated field."""
from authentik.events.models import Event
from authentik.events.models import Event, EventAction
deprecation = "authentik.core.models.User.ak_groups"
replacement = "authentik.core.models.User.groups"
@@ -545,9 +544,21 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
cause=cause,
stacktrace=stacktrace,
)
Event.log_deprecation(
deprecation, message=message_event, cause=cause, replacement=replacement
)
if not Event.filter_not_expired(
action=EventAction.CONFIGURATION_WARNING,
context__deprecation=deprecation,
context__cause=cause,
).exists():
event = Event.new(
EventAction.CONFIGURATION_WARNING,
deprecation=deprecation,
replacement=replacement,
message=message_event,
cause=cause,
)
event.expires = datetime.now() + timedelta(days=30)
event.save()
return self.groups
def set_password(self, raw_password, signal=True, sender=None, request=None):
@@ -1085,24 +1096,12 @@ class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
unique_together = (("group", "source"),)
class ExpiringManager(Manager):
"""Manager for expiring objects which filters out expired objects by default"""
def get_queryset(self):
return QuerySet(self.model, using=self._db).exclude(expires__lt=now(), expiring=True)
def including_expired(self):
return QuerySet(self.model, using=self._db)
class ExpiringModel(models.Model):
"""Base Model which can expire, and is automatically cleaned up."""
expires = models.DateTimeField(default=None, null=True)
expiring = models.BooleanField(default=True)
objects = ExpiringManager()
class Meta:
abstract = True
indexes = [
@@ -1116,33 +1115,13 @@ class ExpiringModel(models.Model):
default the object is deleted. This is less efficient compared
to bulk deleting objects, but classes like Token() need to change
values instead of being deleted."""
try:
return self.delete(*args, **kwargs)
except self.DoesNotExist:
# Object has already been deleted, so this should be fine
return None
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet[Self]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
from authentik.events.models import Event
deprecation_id = f"{class_to_path(cls)}.filter_not_expired"
Event.log_deprecation(
deprecation_id,
message=(
".filter_not_expired() is deprecated as the default lookup now excludes "
"expired objects."
),
)
for obj in (
cls.objects.including_expired()
.filter(**kwargs)
.filter(Q(expires__lt=now(), expiring=True))
):
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
obj.delete()
return cls.objects.filter(**kwargs)

View File

@@ -24,8 +24,7 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any]
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
login_failed = Signal()
LOGGER = get_logger()

View File

@@ -27,10 +27,7 @@ def clean_expired_models():
for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel
objects = (
cls.objects.including_expired()
.all()
.exclude(expiring=False)
.exclude(expiring=True, expires__gt=now())
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
)
amount = objects.count()
for obj in chunked_queryset(objects):

View File

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

View File

@@ -9,8 +9,6 @@ from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.utils.reflection import all_subclasses
@@ -31,22 +29,6 @@ class TestModels(TestCase):
freeze.tick(timedelta(seconds=1))
self.assertFalse(token.is_expired)
def test_filter_not_expired_warning(self):
"""Test filter_not_expired's deprecation message"""
id = generate_id()
Token.objects.create(
expires=now() - timedelta(hours=1),
expiring=True,
user=get_anonymous_user(),
identifier=id,
)
self.assertFalse(Token.filter_not_expired(identifier=id).exists())
event = Event.objects.filter(action=EventAction.CONFIGURATION_WARNING).first()
self.assertIsNotNone(event)
self.assertEqual(
event.context["deprecation"], "authentik.core.models.Token.filter_not_expired"
)
def source_tester_factory(test_model: type[Source]) -> Callable:
"""Test source"""

View File

@@ -173,7 +173,7 @@ class TestTokenAPI(APITestCase):
def test_list(self):
"""Test Token List (Test normal authentication)"""
Token.objects.including_expired().all().delete()
Token.objects.all().delete()
token_should: Token = Token.objects.create(
identifier="test", expiring=False, user=self.user
)
@@ -185,7 +185,7 @@ class TestTokenAPI(APITestCase):
def test_list_with_permission(self):
"""Test Token List (Test with `view_token` permission)"""
Token.objects.including_expired().all().delete()
Token.objects.all().delete()
token_should: Token = Token.objects.create(
identifier="test", expiring=False, user=self.user
)

View File

@@ -1,9 +1,6 @@
"""Test token auth"""
from datetime import timedelta
from django.test import TestCase
from django.utils.timezone import now
from authentik.core.auth import TokenBackend
from authentik.core.models import Token, TokenIntents, User
@@ -31,15 +28,6 @@ class TestTokenAuth(TestCase):
TokenBackend().authenticate(self.request, "test-user", self.token.key), self.user
)
def test_token_auth_expired(self):
"""Test auth with token"""
self.token.expiring = True
self.token.expires = now() - timedelta(hours=1)
self.token.save()
self.assertEqual(
TokenBackend().authenticate(self.request, "test-user", self.token.key), None
)
def test_token_auth_none(self):
"""Test auth with token (non-existent user)"""
self.assertIsNone(

View File

@@ -26,11 +26,7 @@ class RootRedirectView(RedirectView):
query_string = True
def redirect_to_app(self, request: HttpRequest):
if request.user.is_authenticated and request.user.type in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
):
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
brand: Brand = request.brand
if brand.default_application:
return redirect(
@@ -66,11 +62,7 @@ class BrandDefaultRedirectView(InterfaceView):
"""By default redirect to default app"""
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if request.user.is_authenticated and request.user.type in (
UserTypes.EXTERNAL,
UserTypes.SERVICE_ACCOUNT,
UserTypes.INTERNAL_SERVICE_ACCOUNT,
):
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
brand: Brand = request.brand
if brand.default_application:
return redirect(

View File

@@ -114,16 +114,15 @@ def certificate_discovery():
discovered = 0
for file in glob(CONFIG.get("cert_discovery_dir") + "/**", recursive=True):
path = Path(file)
if not path.exists() or path.is_dir():
if not path.exists():
continue
if path.is_dir():
continue
# For certbot setups, we want to ignore archive.
if "archive" in file:
continue
# Handle additionalOutputFormats from cert-manager gracefully
if path.name in ["ca.crt", "tls-combined.pem", "key.der"]:
continue
# Support certbot & kubernetes.io/tls directory structure
if path.name in ["fullchain.pem", "privkey.pem", "tls.crt", "tls.key"]:
# Support certbot's directory structure
if path.name in ["fullchain.pem", "privkey.pem"]:
cert_name = path.parent.name
else:
cert_name = path.name.replace(path.suffix, "")

View File

@@ -355,16 +355,6 @@ class TestCrypto(APITestCase):
subject_alt_names=[],
validity_days=3,
)
name3 = generate_id()
builder3 = CertificateBuilder(name3)
with self.assertRaises(ValueError):
builder3.save()
builder3.build(
subject_alt_names=[],
validity_days=3,
)
with TemporaryDirectory() as temp_dir:
with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
_cert.write(builder.certificate)
@@ -375,8 +365,6 @@ class TestCrypto(APITestCase):
_cert.write(builder2.certificate)
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
_key.write(builder2.private_key)
with open(f"{temp_dir}/tls-combined.pem", "w+", encoding="utf-8") as _cert:
_cert.write(builder3.certificate)
with CONFIG.patch("cert_discovery_dir", temp_dir):
certificate_discovery.send()
keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
@@ -388,9 +376,6 @@ class TestCrypto(APITestCase):
self.assertTrue(
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
)
self.assertFalse(
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "tls-combined").exists()
)
def test_discovery_updating_same_private_key(self):
"""Test certificate discovery updating certs with matching private keys"""

View File

@@ -97,7 +97,7 @@ class DeviceViewSet(
def summary(self, request: Request) -> Response:
delta = now() - timedelta(hours=24)
unreachable = (
Device.objects.all()
Device.filter_not_expired()
.annotate(
latest_snapshot=Subquery(
DeviceFactSnapshot.objects.filter(connection__device=OuterRef("pk"))
@@ -110,7 +110,7 @@ class DeviceViewSet(
.count()
)
data = {
"total_count": Device.objects.all().count(),
"total_count": Device.filter_not_expired().count(),
"unreachable_count": unreachable,
# Currently not supported
"outdated_agent_count": 0,

View File

@@ -65,9 +65,7 @@ class AgentConnectorSerializer(ConnectorSerializer):
class MDMConfigSerializer(PassiveSerializer):
platform = ChoiceField(choices=OSFamily.choices)
enrollment_token = PrimaryKeyRelatedField(
queryset=EnrollmentToken.objects.including_expired().all()
)
enrollment_token = PrimaryKeyRelatedField(queryset=EnrollmentToken.objects.all())
def validate_platform(self, platform: OSFamily) -> OSFamily:
if platform not in [OSFamily.iOS, OSFamily.macOS, OSFamily.windows]:
@@ -138,7 +136,7 @@ class AgentConnectorViewSet(
device=device,
connector=token.connector,
)
DeviceToken.objects.including_expired().filter(device=connection).delete()
DeviceToken.objects.filter(device=connection).delete()
token = DeviceToken.objects.create(device=connection, expiring=False)
return Response(
{

View File

@@ -34,11 +34,9 @@ class AgentEnrollmentAuth(BaseAuthentication):
def authenticate(self, request: Request) -> tuple[User, Any] | None:
auth = get_authorization_header(request)
key = validate_auth(auth)
token = EnrollmentToken.objects.filter(key=key).first()
token = EnrollmentToken.filter_not_expired(key=key).first()
if not token:
raise PermissionDenied()
if not token.connector.enabled:
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token_enrollment")
return (DeviceUser(), token)
@@ -50,11 +48,9 @@ class AgentAuth(BaseAuthentication):
key = validate_auth(auth, format="bearer+agent")
if not key:
return None
device_token = DeviceToken.objects.filter(key=key).first()
device_token = DeviceToken.filter_not_expired(key=key).first()
if not device_token:
raise PermissionDenied()
if not device_token.device.connector.enabled:
raise PermissionDenied()
if device_token.device.device.is_expired:
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token")
@@ -91,7 +87,7 @@ class DeviceAuthFedAuthentication(BaseAuthentication):
if not raw_token:
LOGGER.warning("Missing token")
return None
device = Device.objects.filter(name=request.query_params.get("device")).first()
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
if not device:
LOGGER.warning("Couldn't find device")
return None

View File

@@ -53,11 +53,11 @@ class EndpointAgentChallengeResponse(ChallengeResponse):
except PyJWTError as exc:
self.stage.logger.warning("Could not parse response", exc=exc)
raise ValidationError("Invalid challenge response") from None
device = Device.objects.filter(identifier=raw["iss"]).first()
device = Device.filter_not_expired(identifier=raw["iss"]).first()
if not device:
self.stage.logger.warning("Could not find device for challenge")
raise ValidationError("Invalid challenge response")
for token in DeviceToken.objects.filter(
for token in DeviceToken.filter_not_expired(
device__device=device,
device__connector=self.stage.executor.current_stage.connector,
).values_list("key", flat=True):

View File

@@ -58,16 +58,6 @@ class TestAgentAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_enroll_disabled(self):
self.connector.enabled = False
self.connector.save()
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, 403)
def test_enroll_token_delete(self):
response = self.client.post(
reverse("authentik_api:agentconnector-enroll"),
@@ -89,7 +79,7 @@ class TestAgentAPI(APITestCase):
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
device = Device.objects.filter(identifier=ident).first()
device = Device.filter_not_expired(identifier=ident).first()
self.assertIsNotNone(device)
self.assertEqual(device.access_group, device_group)
@@ -104,7 +94,7 @@ class TestAgentAPI(APITestCase):
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(Device.objects.filter(identifier=dev_id).exists())
self.assertFalse(Device.filter_not_expired(identifier=dev_id).exists())
@reconcile_app("authentik_crypto")
def test_config(self):
@@ -114,16 +104,6 @@ class TestAgentAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
@reconcile_app("authentik_crypto")
def test_config_disabled(self):
self.connector.enabled = False
self.connector.save()
response = self.client.get(
reverse("authentik_api:agentconnector-agent-config"),
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 403)
def test_check_in(self):
response = self.client.post(
reverse("authentik_api:agentconnector-check-in"),
@@ -132,16 +112,6 @@ class TestAgentAPI(APITestCase):
)
self.assertEqual(response.status_code, 204)
def test_check_in_disabled(self):
self.connector.enabled = False
self.connector.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_token_expired(self):
self.device_token.expiring = True
self.device_token.expires = now() - timedelta(hours=1)

View File

@@ -44,6 +44,3 @@ class BaseController[T: "Connector"]:
def stage_view_authentication(self) -> StageView | None:
return None
def sync_endpoints(self):
raise NotImplementedError

View File

@@ -54,7 +54,7 @@ class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindi
def facts(self) -> DeviceFactSnapshot:
data = {}
last_updated = datetime.fromtimestamp(0, UTC)
for snapshot_data, snapshort_created in DeviceFactSnapshot.objects.filter(
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
@@ -162,11 +162,8 @@ class Connector(ScheduledModel, SerializerModel):
@property
def schedule_specs(self) -> list[ScheduleSpec]:
from authentik.endpoints.controller import Capabilities
from authentik.endpoints.tasks import endpoints_sync
if Capabilities.ENROLL_AUTOMATIC_API not in self.controller(self).capabilities():
return []
return [
ScheduleSpec(
actor=endpoints_sync,

View File

@@ -1,4 +1,4 @@
from authentik.endpoints.models import Connector, EndpointStage, StageMode
from authentik.endpoints.models import EndpointStage, StageMode
from authentik.flows.stage import StageView
PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
@@ -8,10 +8,7 @@ class EndpointStageView(StageView):
def _get_inner(self) -> StageView | None:
stage: EndpointStage = self.executor.current_stage
connector: Connector = stage.connector
if not connector.enabled:
return None
inner_stage: type[StageView] | None = connector.stage
inner_stage: type[StageView] | None = stage.connector.stage
if not inner_stage:
return None
return inner_stage(self.executor, request=self.request)

View File

@@ -17,11 +17,11 @@ def endpoints_sync(connector_pk: Any):
connector: Connector | None = (
Connector.objects.filter(pk=connector_pk).select_subclasses().first()
)
if not connector or not connector.enabled:
if not connector:
return
controller = connector.controller
ctrl = controller(connector)
if Capabilities.ENROLL_AUTOMATIC_API not in ctrl.capabilities():
if Capabilities.AUTOMATIC_API not in ctrl.capabilities():
return
LOGGER.info("Syncing connector", connector=connector.name)
ctrl.sync_endpoints()

View File

@@ -1,35 +0,0 @@
from unittest.mock import PropertyMock, patch
from rest_framework.test import APITestCase
from authentik.endpoints.controller import BaseController, Capabilities
from authentik.endpoints.models import Connector
from authentik.endpoints.tasks import endpoints_sync
from authentik.lib.generators import generate_id
class TestEndpointTasks(APITestCase):
def test_agent_sync(self):
class controller(BaseController):
def capabilities(self):
return [Capabilities.ENROLL_AUTOMATIC_API]
def sync_endpoints(self):
pass
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
connector = Connector.objects.create(name=generate_id())
self.assertEqual(len(connector.schedule_specs), 1)
endpoints_sync.send(connector.pk).get_result(block=True)
def test_agent_no_sync(self):
class controller(BaseController):
def capabilities(self):
return []
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
connector = Connector.objects.create(name=generate_id())
self.assertEqual(len(connector.schedule_specs), 0)
endpoints_sync.send(connector.pk).get_result(block=True)

View File

@@ -1,7 +1,6 @@
"""Enterprise app config"""
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from authentik.enterprise.apps import EnterpriseConfig
from authentik.tenants.flags import Flag
@@ -10,9 +9,6 @@ from authentik.tenants.flags import Flag
class AuditIncludeExpandedDiff(Flag[bool], key="enterprise_audit_include_expanded_diff"):
default = False
visibility = "none"
description = _(
"Include additional information in audit logs, may incur a performance penalty."
)
class AuthentikEnterpriseAuditConfig(EnterpriseConfig):

View File

@@ -17,7 +17,7 @@ class NonceView(View):
def post(self, request: HttpRequest, *args, **kwargs):
raw_token = unquote(self.request.POST.get("x-ak-device-token"))
device_token = DeviceToken.objects.filter(key=raw_token).first()
device_token = DeviceToken.filter_not_expired(key=raw_token).first()
if not device_token:
return HttpResponseBadRequest()
nonce = AppleNonce.objects.create(

View File

@@ -106,7 +106,7 @@ class RegisterUserView(APIView):
def post(self, request: Request, body: AgentPSSOUserRegistration) -> Response:
device_token: DeviceToken = request.auth
conn: AgentDeviceConnection = device_token.device
user_token = DeviceAuthenticationToken.objects.filter(
user_token = DeviceAuthenticationToken.filter_not_expired(
device=conn.device,
token=body.validated_data["user_auth"],
device_token=device_token,

View File

@@ -96,7 +96,7 @@ class TokenView(View):
self.remote_nonce = decoded.get("nonce")
# Check that the nonce hasn't been used before
nonce = AppleNonce.objects.filter(nonce=decoded["request_nonce"]).first()
nonce = AppleNonce.filter_not_expired(nonce=decoded["request_nonce"]).first()
if not nonce:
raise ValidationError("Invalid nonce")
self.nonce = nonce

View File

@@ -3,7 +3,6 @@ from hmac import compare_digest
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
from authentik.common.oauth.constants import QS_LOGIN_HINT
from authentik.endpoints.connectors.agent.auth import (
agent_auth_issue_token,
check_device_policies,
@@ -15,7 +14,7 @@ from authentik.enterprise.policy import EnterprisePolicyAccessView
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlanner
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
from authentik.flows.stage import StageView
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
QS_AGENT_IA_TOKEN = "ak-auth-ia-token" # nosec
@@ -30,7 +29,7 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
def resolve_provider_application(self):
auth_token = (
DeviceAuthenticationToken.objects.filter(identifier=self.kwargs["token_uuid"])
DeviceAuthenticationToken.filter_not_expired(identifier=self.kwargs["token_uuid"])
.prefetch_related()
.first()
)
@@ -65,14 +64,14 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
planner = FlowPlanner(self.connector.authorization_flow)
planner.allow_empty_flows = True
context = {
PLAN_CONTEXT_DEVICE: self.device,
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
}
if QS_LOGIN_HINT in request.GET:
context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = request.GET[QS_LOGIN_HINT]
try:
plan = planner.plan(self.request, context)
plan = planner.plan(
self.request,
{
PLAN_CONTEXT_DEVICE: self.device,
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
},
)
except FlowNonApplicableException:
return self.handle_no_permission_authenticated()
plan.append_stage(in_memory_stage(AgentAuthFulfillmentStage))
@@ -85,6 +84,7 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
class AgentAuthFulfillmentStage(StageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
device: Device = self.executor.plan.context.pop(PLAN_CONTEXT_DEVICE)
auth_token: DeviceAuthenticationToken = self.executor.plan.context.pop(

View File

@@ -8,7 +8,6 @@ from dramatiq.actor import actor
from requests.exceptions import RequestException
from structlog.stdlib import get_logger
from authentik.core.apps import AppAccessWithoutBindings
from authentik.core.models import User
from authentik.enterprise.providers.ssf.models import (
DeliveryMethods,
@@ -69,7 +68,6 @@ def _check_app_access(stream: Stream, event_data: dict) -> bool:
if not user:
return True
engine = PolicyEngine(stream.provider.backchannel_application, user)
engine.empty_result = AppAccessWithoutBindings.get()
engine.use_cache = False
engine.build()
return engine.passing

View File

@@ -25,7 +25,7 @@ class SSFTokenAuth(BaseAuthentication):
def check_token(self, key: str) -> Token | None:
"""Check that a token exists, is not expired, and is assigned to the correct provider"""
token = Token.objects.filter(key=key, intent=TokenIntents.INTENT_API).first()
token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first()
if not token:
return None
provider: SSFProvider = token.ssfprovider_set.first()
@@ -39,7 +39,7 @@ class SSFTokenAuth(BaseAuthentication):
"""Check JWT-based authentication, this supports tokens issued either by providers
configured directly in the provider, and by providers assigned to the application
that the SSF provider is a backchannel provider of."""
token = AccessToken.objects.filter(token=jwt, revoked=False).first()
token = AccessToken.filter_not_expired(token=jwt, revoked=False).first()
if not token:
return None
ssf_provider = SSFProvider.objects.filter(

View File

@@ -1,11 +1,11 @@
SPECTACULAR_SETTINGS = {
"POSTPROCESSING_HOOKS": [
"authentik.api.v3.schema.response.postprocess_schema_register",
"authentik.api.v3.schema.response.postprocess_schema_responses",
"authentik.api.v3.schema.query.postprocess_schema_query_params",
"authentik.api.v3.schema.cleanup.postprocess_schema_remove_unused",
"authentik.api.schema.postprocess_schema_register",
"authentik.api.schema.postprocess_schema_responses",
"authentik.api.schema.postprocess_schema_query_params",
"authentik.api.schema.postprocess_schema_remove_unused",
"authentik.enterprise.search.schema.postprocess_schema_search_autocomplete",
"authentik.api.v3.schema.enum.postprocess_schema_enums",
"drf_spectacular.hooks.postprocess_schema_enums",
],
}

View File

@@ -63,7 +63,6 @@ class NotificationTransportSerializer(ModelSerializer):
"mode",
"mode_verbose",
"webhook_url",
"webhook_ca",
"webhook_mapping_body",
"webhook_mapping_headers",
"email_subject_prefix",

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-10 10:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0006_certificatekeypair_cert_expiry_and_more"),
("authentik_events", "0016_alter_event_action"),
]
operations = [
migrations.AddField(
model_name="notificationtransport",
name="webhook_ca",
field=models.ForeignKey(
default=None,
help_text="When set, the selected ceritifcate is used to validate the certificate of the webhook server.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_crypto.certificatekeypair",
),
),
]

View File

@@ -12,7 +12,6 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.apps import apps
from django.db import models
from django.db.models import Q
from django.http import HttpRequest
from django.http.request import QueryDict
from django.utils.timezone import now
@@ -29,7 +28,6 @@ from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_USER,
)
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
from authentik.crypto.models import CertificateKeyPair
from authentik.events.context_processors.base import get_context_processors
from authentik.events.utils import (
cleanse_dict,
@@ -43,7 +41,6 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_dict
from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.outposts.docker_tls import DockerInlineTLS
from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.root.ws.consumer import build_user_group
@@ -251,28 +248,6 @@ class Event(SerializerModel, ExpiringModel):
self.save()
return self
@staticmethod
def log_deprecation(
identifier: str, message: str, cause: str | None = None, expiry_days=30, **kwargs
):
query = Q(
action=EventAction.CONFIGURATION_WARNING,
context__deprecation=identifier,
)
if cause:
query &= Q(context__cause=cause)
if Event.objects.filter(query).exists():
return
event = Event.new(
EventAction.CONFIGURATION_WARNING,
deprecation=identifier,
message=message,
cause=cause,
**kwargs,
)
event.expires = now() + timedelta(days=expiry_days)
event.save()
def save(self, *args, **kwargs):
if self._state.adding:
LOGGER.info(
@@ -351,16 +326,6 @@ class NotificationTransport(TasksModel, SerializerModel):
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
webhook_ca = models.ForeignKey(
CertificateKeyPair,
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_(
"When set, the selected ceritifcate is used to "
"validate the certificate of the webhook server."
),
)
webhook_mapping_body = models.ForeignKey(
"NotificationWebhookMapping",
on_delete=models.SET_DEFAULT,
@@ -444,29 +409,21 @@ class NotificationTransport(TasksModel, SerializerModel):
notification=notification,
)
)
def send(**kwargs):
try:
response = get_http_session().post(
self.webhook_url,
json=default_body,
headers=headers,
**kwargs,
)
response.raise_for_status()
except RequestException as exc:
raise NotificationTransportError(
exc.response.text if exc.response else str(exc)
) from exc
return [
response.status_code,
response.text,
]
if self.webhook_ca:
with DockerInlineTLS(self.webhook_ca, authentication_kp=None) as tls:
return send(verify=tls.ca_cert)
return send()
try:
response = get_http_session().post(
self.webhook_url,
json=default_body,
headers=headers,
)
response.raise_for_status()
except RequestException as exc:
raise NotificationTransportError(
exc.response.text if exc.response else str(exc)
) from exc
return [
response.status_code,
response.text,
]
def send_webhook_slack(self, notification: Notification) -> list[str]:
"""Send notification to slack or slack-compatible endpoints"""

View File

@@ -93,13 +93,11 @@ def on_login_failed(
credentials: dict[str, str],
request: HttpRequest,
stage: Stage | None = None,
context: dict[str, Any] | None = None,
**kwargs,
):
"""Failed Login, authentik custom event"""
user = User.objects.filter(username=credentials.get("username")).first()
context = context or {}
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **context).from_http(
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(
request, user
)

View File

@@ -207,9 +207,3 @@ class TestEvents(TestCase):
"username": user.username,
},
)
def test_invalid_string(self):
"""Test creating an event with invalid unicode string data"""
event = Event.new("unittest", foo="foo bar \u0000 baz")
event.save()
self.assertEqual(event.context["foo"], "foo bar baz")

View File

@@ -10,7 +10,6 @@ from requests_mock import Mocker
from authentik import authentik_full_version
from authentik.core.tests.utils import create_test_admin_user
from authentik.crypto.models import CertificateKeyPair
from authentik.events.api.notification_transports import NotificationTransportSerializer
from authentik.events.models import (
Event,
@@ -62,37 +61,6 @@ class TestEventTransports(TestCase):
},
)
def test_transport_webhook_ca_invalid_unset(self):
"""Test webhook transport"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.WEBHOOK,
webhook_url="https://localhost:1234/test",
)
with Mocker() as mocker:
mocker.post("https://localhost:1234/test")
transport.send(self.notification)
self.assertEqual(mocker.call_count, 1)
self.assertTrue(mocker.request_history[0].verify)
def test_transport_webhook_ca(self):
"""Test webhook transport"""
kp = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data="foo",
)
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.WEBHOOK,
webhook_url="https://localhost:1234/test",
webhook_ca=kp,
)
with Mocker() as mocker:
mocker.post("https://localhost:1234/test")
transport.send(self.notification)
self.assertEqual(mocker.call_count, 1)
self.assertIsNotNone(mocker.request_history[0].verify)
def test_transport_webhook_mapping(self):
"""Test webhook transport with custom mapping"""
mapping_body = NotificationWebhookMapping.objects.create(

View File

@@ -36,10 +36,6 @@ ALLOWED_SPECIAL_KEYS = re.compile(
)
def cleanse_str(raw: Any) -> str:
return str(raw).replace("\u0000", "")
def cleanse_item(key: str, value: Any) -> Any:
"""Cleanse a single item"""
if isinstance(value, dict):
@@ -70,7 +66,7 @@ def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:
def model_to_dict(model: Model) -> dict[str, Any]:
"""Convert model to dict"""
name = cleanse_str(model)
name = str(model)
if hasattr(model, "name"):
name = model.name
return {
@@ -137,11 +133,11 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
if isinstance(value, ASN):
return ASN_CONTEXT_PROCESSOR.asn_to_dict(value)
if isinstance(value, Path):
return cleanse_str(value)
return str(value)
if isinstance(value, Exception):
return cleanse_str(value)
return str(value)
if isinstance(value, YAMLTag):
return cleanse_str(value)
return str(value)
if isinstance(value, Enum):
return value.value
if isinstance(value, type):
@@ -165,7 +161,7 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
raise ValueError("JSON can't represent timezone-aware times.")
return value.isoformat()
if isinstance(value, timedelta):
return cleanse_str(value.total_seconds())
return str(value.total_seconds())
if callable(value):
return {
"type": "callable",
@@ -178,8 +174,8 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
try:
return DjangoJSONEncoder().default(value)
except TypeError:
return cleanse_str(value)
return cleanse_str(value)
return str(value)
return str(value)
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:

View File

@@ -1,6 +1,5 @@
"""authentik flows app config"""
from django.utils.translation import gettext_lazy as _
from prometheus_client import Gauge, Histogram
from authentik.blueprints.apps import ManagedAppConfig
@@ -28,14 +27,12 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
default = False
visibility = "public"
description = _("Refresh other tabs after successful authentication.")
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
default = False
visibility = "public"
description = _("Upon successful authentication, re-start authentication in other open tabs.")
class AuthentikFlowsConfig(ManagedAppConfig):

View File

@@ -25,8 +25,6 @@
window.authentik.flow = {
"layout": "{{ flow.layout }}",
"background": "{{ flow.background }}",
"title": "{{ flow.title }}",
};
</script>
{% endblock %}
@@ -37,43 +35,14 @@
{% block head %}
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style data-id="flow-css">
:root {
--ak-global--background-image: url("{{ flow_background_url }}");
}
</style>
{% endblock %}
{% block body %}
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container></ak-message-container>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-flow-executor
slug="{{ flow.slug }}"
class="pf-c-login"
data-layout="{{ flow.layout|default:'stacked' }}"
loading
>
{% include "base/placeholder.html" %}
<ak-brand-links name="flow-links" slot="footer"></ak-brand-links>
</ak-flow-executor>
</div>
</div>
<ak-flow-inspector
id="flow-inspector"
data-registration="lazy"
class="pf-c-drawer__panel pf-m-width-33"
slug="{{ flow.slug }}"
></ak-flow-inspector>
</div>
</div>
</div>
</div>
<ak-flow
slug="{{ flow.slug }}"
layout={{ flow.layout|default:'stacked' }}"
style='--ak-global--background-image: url("{{ flow_background_url }}")'
></ak-flow>
{% endblock %}

View File

@@ -134,7 +134,7 @@ class FlowExecutorView(APIView):
def _check_flow_token(self, key: str) -> FlowPlan | None:
"""Check if the user is using a flow token to restore a plan"""
token: FlowToken | None = FlowToken.objects.filter(key=key).first()
token: FlowToken | None = FlowToken.filter_not_expired(key=key).first()
if not token:
return None
plan = None

View File

@@ -1,32 +0,0 @@
from django.apps.registry import apps
from django.db.models import TextChoices
from django.utils.encoding import force_str
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel
def app_choices() -> TextChoices:
"""Get a list of all installed applications that create events.
Returns a list of tuples containing (dotted.app.path, name)"""
choices = {}
for app in apps.get_app_configs():
if app.label.startswith("authentik"):
choices[app.name] = (app.name, force_str(app.verbose_name))
return TextChoices("Apps", choices)
def model_choices() -> TextChoices:
"""Get a list of all installed models
Returns a list of tuples containing (dotted.model.path, name)"""
choices = {}
for model in apps.get_models():
if not is_model_allowed(model) or issubclass(model, BaseMetaModel):
continue
name = f"{model._meta.app_label}.{model._meta.model_name}"
choices[name] = (name, force_str(model._meta.verbose_name))
return TextChoices("Models", choices)
Apps = app_choices()
Models = model_choices()

View File

@@ -423,5 +423,4 @@ if __name__ == "__main__":
if len(argv) < 2: # noqa: PLR2004
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))
else:
for arg in argv[1:]:
print(CONFIG.get(arg))
print(CONFIG.get(argv[-1]))

View File

@@ -22,7 +22,6 @@ postgresql:
port: 5432
password: "env://POSTGRES_PASSWORD"
sslmode: disable
conn_health_checks: false
use_pool: False
test:
name: test_authentik
@@ -33,18 +32,12 @@ postgresql:
# host: replica1.example.com
listen:
http:
- "[::]:9000"
https:
- "[::]:9443"
ldap:
- "[::]:3389"
ldaps:
- "[::]:6636"
radius:
- "[::]:1812"
metrics:
- "[::]:9300"
http: 0.0.0.0:9000
https: 0.0.0.0:9443
ldap: 0.0.0.0:3389
ldaps: 0.0.0.0:6636
radius: 0.0.0.0:1812
metrics: 0.0.0.0:9300
debug: 0.0.0.0:9900
debug_py: 0.0.0.0:9901
trusted_proxy_cidrs:
@@ -144,7 +137,8 @@ tenants:
blueprints_dir: /blueprints
web:
workers: 2
# No default here as it's set dynamically
# workers: 2
threads: 4
path: /
timeout_http_read_header: 5s
@@ -189,5 +183,3 @@ storage:
# backend: file # or s3
# file: {}
# s3: {}
skip_migrations: false

View File

@@ -1,14 +1,16 @@
"""authentik sentry integration"""
from asyncio.exceptions import CancelledError
from typing import TYPE_CHECKING, Any
from typing import Any
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation, ValidationError
from django.db import DatabaseError, InternalError, OperationalError, ProgrammingError
from django.http.response import Http404
from docker.errors import DockerException
from dramatiq.errors import Retry
from h11 import LocalProtocolError
from ldap3.core.exceptions import LDAPException
from psycopg.errors import Error
from rest_framework.exceptions import APIException
from sentry_sdk import HttpTransport, get_current_scope
@@ -28,11 +30,6 @@ from authentik import authentik_build_hash, authentik_version
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import authentik_user_agent
from authentik.lib.utils.reflection import get_env
from authentik.tasks import TASK_WORKER
if TYPE_CHECKING or TASK_WORKER:
from docker.errors import DockerException
from ldap3.core.exceptions import LDAPException
LOGGER = get_logger()
_root_path = CONFIG.get("web.path", "/")
@@ -66,6 +63,10 @@ ignored_classes = (
Retry,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
@@ -131,14 +132,6 @@ def traces_sampler(sampling_context: dict) -> float:
def should_ignore_exception(exc: Exception) -> bool:
"""Check if an exception should be dropped"""
if TASK_WORKER and isinstance(
exc,
# ldap errors
LDAPException |
# Docker errors
DockerException,
):
return True
return isinstance(exc, ignored_classes)

View File

@@ -4,10 +4,12 @@ from dataclasses import asdict
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from kubernetes.client.configuration import Configuration
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.kube_config import load_kube_config_from_dict
from rest_framework import mixins, serializers
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, ReadOnlyField
from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ModelViewSet
@@ -24,7 +26,6 @@ from authentik.outposts.models import (
KubernetesServiceConnection,
OutpostServiceConnection,
)
from authentik.outposts.tasks import outpost_validate_kubeconfig
from authentik.rbac.filters import ObjectFilter
@@ -61,10 +62,10 @@ class ServiceConnectionStateSerializer(PassiveSerializer):
class ServiceConnectionViewSet(
TypesMixin,
RetrieveModelMixin,
DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
ListModelMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""ServiceConnection Viewset"""
@@ -111,12 +112,16 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
"""Validate kubeconfig by attempting to load it"""
if kubeconfig == {}:
if not self.initial_data["local"]:
raise ValidationError(
raise serializers.ValidationError(
_("You can only use an empty kubeconfig when connecting to a local cluster.")
)
# Empty kubeconfig is valid
return kubeconfig
outpost_validate_kubeconfig.send_with_options((kubeconfig,))
config = Configuration()
try:
load_kube_config_from_dict(kubeconfig, client_configuration=config)
except ConfigException:
raise serializers.ValidationError(_("Invalid kubeconfig")) from None
return kubeconfig
class Meta:

View File

@@ -185,10 +185,8 @@ class KubernetesObjectReconciler[T]:
patch = self.get_patch()
if patch is not None:
try:
current_json = ApiClient().sanitize_for_serialization(current)
except AttributeError:
current_json = asdict(current)
current_json = ApiClient().sanitize_for_serialization(current)
try:
if apply_patch(current_json, patch) != current_json:
raise NeedsUpdate()

View File

@@ -1,19 +1,17 @@
"""k8s utils"""
from pathlib import Path
from typing import TYPE_CHECKING
from kubernetes.client.models.v1_container_port import V1ContainerPort
from kubernetes.client.models.v1_service_port import V1ServicePort
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate
from authentik.tasks import TASK_WORKER
if TYPE_CHECKING or TASK_WORKER:
from kubernetes.client.models.v1_container_port import V1ContainerPort
from kubernetes.client.models.v1_service_port import V1ServicePort
def get_namespace() -> str:
"""Get the namespace if we're running in a pod, otherwise default to default"""
path = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
path = Path(SERVICE_TOKEN_FILENAME.replace("token", "namespace"))
if path.exists():
with open(path, encoding="utf8") as _namespace_file:
return _namespace_file.read()

View File

@@ -27,12 +27,6 @@ class DockerInlineTLS:
self.authentication_kp = authentication_kp
self._paths = []
def __enter__(self):
return self.write()
def __exit__(self, exc_type, exc, tb):
self.cleanup()
def write_file(self, name: str, contents: str) -> str:
"""Wrapper for mkstemp that uses fdopen"""
path = Path(gettempdir(), name)

View File

@@ -403,7 +403,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
def token(self) -> Token:
"""Get/create token for auto-generated user"""
managed = f"goauthentik.io/outpost/{self.token_identifier}"
tokens = Token.objects.filter(
tokens = Token.filter_not_expired(
identifier=self.token_identifier,
intent=TokenIntents.INTENT_API,
managed=managed,

View File

@@ -163,5 +163,4 @@ def outpost_pre_delete_cleanup(sender, instance: Outpost, **_):
@receiver(pre_delete, sender=AuthenticatedSession)
def outpost_logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""
if Outpost.objects.exists():
outpost_session_end.send(instance.session.session_key)
outpost_session_end.send(instance.session.session_key)

View File

@@ -4,54 +4,41 @@ from hashlib import sha256
from os import R_OK, access
from pathlib import Path
from socket import gethostname
from typing import TYPE_CHECKING, Any
from typing import Any
from urllib.parse import urlparse
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from django_dramatiq_postgres.middleware import CurrentTask
from docker.constants import DEFAULT_UNIX_SOCKET
from dramatiq.actor import actor
from rest_framework.exceptions import ValidationError
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
from structlog.stdlib import get_logger
from yaml import safe_load
from authentik.lib.config import CONFIG
from authentik.outposts.consumer import build_outpost_group
from authentik.tasks import TASK_WORKER
if TYPE_CHECKING or TASK_WORKER:
from docker.constants import DEFAULT_UNIX_SOCKET
from kubernetes.client.configuration import Configuration
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
from kubernetes.config.kube_config import (
KUBE_CONFIG_DEFAULT_LOCATION,
load_kube_config_from_dict,
)
from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.controllers.docker import DockerClient
from authentik.outposts.controllers.kubernetes import KubernetesClient
from authentik.outposts.models import (
DockerServiceConnection,
KubernetesServiceConnection,
Outpost,
OutpostServiceConnection,
OutpostType,
ServiceConnectionInvalid,
)
from authentik.providers.ldap.controllers.docker import LDAPDockerController
from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController
from authentik.providers.proxy.controllers.docker import ProxyDockerController
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from authentik.providers.rac.controllers.docker import RACDockerController
from authentik.providers.rac.controllers.kubernetes import RACKubernetesController
from authentik.providers.radius.controllers.docker import RadiusDockerController
from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController
from authentik.tasks.middleware import CurrentTask
from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.controllers.docker import DockerClient
from authentik.outposts.controllers.kubernetes import KubernetesClient
from authentik.outposts.models import (
DockerServiceConnection,
KubernetesServiceConnection,
Outpost,
OutpostServiceConnection,
OutpostType,
ServiceConnectionInvalid,
)
from authentik.providers.ldap.controllers.docker import LDAPDockerController
from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController
from authentik.providers.proxy.controllers.docker import ProxyDockerController
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from authentik.providers.rac.controllers.docker import RACDockerController
from authentik.providers.rac.controllers.kubernetes import RACKubernetesController
from authentik.providers.radius.controllers.docker import RadiusDockerController
from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController
from authentik.tasks.middleware import CurrentTask
LOGGER = get_logger()
CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s"
@@ -229,13 +216,3 @@ def outpost_session_end(session_id: str):
"session_id": hashed_session_id,
},
)
@actor(description=_("Validate kubeconfig"), throws=ValidationError)
def outpost_validate_kubeconfig(kubeconfig: dict[str, Any]):
config = Configuration()
try:
load_kube_config_from_dict(kubeconfig, client_configuration=config)
except ConfigException:
raise ValidationError(_("Invalid kubeconfig")) from None
return kubeconfig

View File

@@ -1,4 +1,4 @@
"""authentik policies app config
"""Authentik policies app config
Every system policy should be its own Django app under the `policies` app.
For example: The 'dummy' policy is available at `authentik.policies.dummy`.
@@ -7,6 +7,7 @@ For example: The 'dummy' policy is available at `authentik.policies.dummy`.
from prometheus_client import Gauge, Histogram
from authentik.blueprints.apps import ManagedAppConfig
from authentik.tenants.flags import Flag
GAUGE_POLICIES_CACHED = Gauge(
"authentik_policies_cached",
@@ -31,6 +32,12 @@ HIST_POLICIES_EXECUTION_TIME = Histogram(
)
class BufferedPolicyAccessViewFlag(Flag[bool], key="policies_buffered_access_view"):
default = False
visibility = "public"
class AuthentikPoliciesConfig(ManagedAppConfig):
"""authentik policies app config"""
@@ -38,3 +45,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@@ -6,9 +6,8 @@ from rest_framework.fields import ChoiceField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.lib.api import app_choices, model_choices
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.event_matcher.models import EventMatcherPolicy
from authentik.policies.event_matcher.models import EventMatcherPolicy, app_choices, model_choices
class EventMatcherPolicySerializer(PolicySerializer):

View File

@@ -2,11 +2,14 @@
from itertools import chain
from django.apps import apps
from django.db import models
from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel
from authentik.events.models import Event, EventAction
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
@@ -14,6 +17,28 @@ from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
def app_choices() -> list[tuple[str, str]]:
"""Get a list of all installed applications that create events.
Returns a list of tuples containing (dotted.app.path, name)"""
choices = []
for app in apps.get_app_configs():
if app.label.startswith("authentik"):
choices.append((app.name, app.verbose_name))
return choices
def model_choices() -> list[tuple[str, str]]:
"""Get a list of all installed models
Returns a list of tuples containing (dotted.model.path, name)"""
choices = []
for model in apps.get_models():
if not is_model_allowed(model) or issubclass(model, BaseMetaModel):
continue
name = f"{model._meta.app_label}.{model._meta.model_name}"
choices.append((name, model._meta.verbose_name))
return choices
class EventMatcherPolicy(Policy):
"""Passes when Event matches selected criteria."""

View File

@@ -79,7 +79,7 @@ class ReputationViewSet(
):
"""Reputation Viewset"""
queryset = Reputation.objects.including_expired().all()
queryset = Reputation.objects.all()
serializer_class = ReputationSerializer
search_fields = ["identifier", "ip", "score"]
filterset_class = ReputationFilter

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-06 00:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_reputation", "0010_alter_reputation_managers"),
]
operations = [
migrations.AlterModelManagers(
name="reputation",
managers=[],
),
]

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