mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
1 Commits
flows/corr
...
patternfly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6dce71716 |
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -21,12 +21,12 @@ runs:
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
- name: Install Python deps
|
||||
@@ -35,7 +35,7 @@ runs:
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup node
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v4
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Setup go
|
||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup docker cache
|
||||
|
||||
17
.github/actions/setup/docker-compose.yml
vendored
17
.github/actions/setup/docker-compose.yml
vendored
@@ -16,24 +16,7 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: always
|
||||
s3:
|
||||
container_name: s3
|
||||
image: docker.io/zenko/cloudserver
|
||||
environment:
|
||||
REMOTE_MANAGEMENT_DISABLE: "1"
|
||||
SCALITY_ACCESS_KEY_ID: accessKey1
|
||||
SCALITY_SECRET_ACCESS_KEY: secretKey1
|
||||
ports:
|
||||
- 8020:8000
|
||||
volumes:
|
||||
- s3-data:/usr/src/app/localData
|
||||
- s3-metadata:/usr/scr/app/localMetadata
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
s3-data:
|
||||
driver: local
|
||||
s3-metadata:
|
||||
driver: local
|
||||
|
||||
6
.github/actions/test-results/action.yml
vendored
6
.github/actions/test-results/action.yml
vendored
@@ -8,15 +8,15 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
file: unittest.xml
|
||||
use_oidc: true
|
||||
report_type: test_results
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
111
.github/dependabot.yml
vendored
111
.github/dependabot.yml
vendored
@@ -1,7 +1,5 @@
|
||||
version: 2
|
||||
updates:
|
||||
#region Github Actions
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directories:
|
||||
- /
|
||||
@@ -20,11 +18,6 @@ updates:
|
||||
prefix: "ci:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golang
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -35,74 +28,11 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Web
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/"
|
||||
- "/web"
|
||||
- "/web/packages/*"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
labels:
|
||||
- dependencies
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
- "babel-*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
- "@typescript-eslint/*"
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
storybook:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "*storybook*"
|
||||
bundler:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
- "esbuild*"
|
||||
- "@vitest/*"
|
||||
- "vitest"
|
||||
rollup:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
- "rollup-*"
|
||||
- "rollup*"
|
||||
swc:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
- "swc-*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
|
||||
#endregion
|
||||
|
||||
#region NPM Packages
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/web/packages/sfe"
|
||||
- "/web/packages/core"
|
||||
- "/packages/esbuild-plugin-live-reload"
|
||||
- "/packages/prettier-config"
|
||||
- "/packages/tsconfig"
|
||||
@@ -115,11 +45,12 @@ updates:
|
||||
- dependencies
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "core, web:"
|
||||
prefix: "web:"
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
- "@spotlightjs/*"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
@@ -135,12 +66,10 @@ updates:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "*storybook*"
|
||||
bundler:
|
||||
esbuild:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
- "esbuild*"
|
||||
- "@vitest/*"
|
||||
- "vitest"
|
||||
rollup:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
@@ -150,6 +79,9 @@ updates:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
- "swc-*"
|
||||
wdio:
|
||||
patterns:
|
||||
- "@wdio/*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
@@ -159,11 +91,6 @@ updates:
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
|
||||
#endregion
|
||||
|
||||
# #region Documentation
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
@@ -178,7 +105,6 @@ updates:
|
||||
docusaurus:
|
||||
patterns:
|
||||
- "@docusaurus/*"
|
||||
- "@goauthentik/docusaurus-config"
|
||||
build:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
@@ -187,9 +113,7 @@ updates:
|
||||
- "@rspack/binding*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/eslint-config"
|
||||
- "@goauthentik/prettier-config"
|
||||
- "@goauthentik/tsconfig"
|
||||
- "@goauthentik/*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
@@ -197,11 +121,6 @@ updates:
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
|
||||
#endregion
|
||||
|
||||
# AWS Lifecycle
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/lifecycle/aws"
|
||||
schedule:
|
||||
@@ -212,11 +131,6 @@ updates:
|
||||
prefix: "lifecycle/aws:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Python
|
||||
|
||||
- package-ecosystem: uv
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -227,11 +141,6 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Docker
|
||||
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- /
|
||||
@@ -257,5 +166,3 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -2,10 +2,6 @@
|
||||
👋 Hi there! Welcome.
|
||||
|
||||
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
|
||||
|
||||
⚠️ IMPORTANT: Make sure you are opening this PR from a FEATURE BRANCH, not from your main branch!
|
||||
If you opened this PR from your main branch, please close it and create a new feature branch instead.
|
||||
For more information, see: https://docs.goauthentik.io/developer-docs/contributing/#always-use-feature-branches
|
||||
-->
|
||||
|
||||
## Details
|
||||
|
||||
1
.github/transifex.yml
vendored
1
.github/transifex.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
git:
|
||||
filters:
|
||||
- filter_type: file
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -73,12 +73,14 @@ jobs:
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: generate ts client
|
||||
if: ${{ !inputs.release }}
|
||||
run: make gen-client-ts
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
matrix:
|
||||
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
|
||||
8
.github/workflows/api-ts-publish.yml
vendored
8
.github/workflows/api-ts-publish.yml
vendored
@@ -18,14 +18,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
16
.github/workflows/ci-api-docs.yml
vendored
16
.github/workflows/ci-api-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Install Dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build -w api
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
- lint
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/ci-aws-cfn.yml
vendored
4
.github/workflows/ci-aws-cfn.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
check-changes-applied:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: lifecycle/aws/package.json
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/ci-docs-source.yml
vendored
2
.github/workflows/ci-docs-source.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: generate docs
|
||||
|
||||
12
.github/workflows/ci-docs.yml
vendored
12
.github/workflows/ci-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Install dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- version-2025-4
|
||||
- version-2025-2
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- run: |
|
||||
current="$(pwd)"
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
|
||||
20
.github/workflows/ci-main.yml
vendored
20
.github/workflows/ci-main.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
- mypy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run job
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations
|
||||
@@ -71,12 +71,14 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: checkout stable
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
# Copy current, latest config to local
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
# Previous stable tag
|
||||
@@ -87,7 +89,7 @@ jobs:
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout ${prev_stable}
|
||||
git checkout $(prev_stable)
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: Setup authentik env (stable)
|
||||
@@ -136,7 +138,7 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -156,7 +158,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
@@ -194,14 +196,14 @@ jobs:
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
@@ -260,7 +262,7 @@ jobs:
|
||||
pull-requests: write
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: prepare variables
|
||||
|
||||
18
.github/workflows/ci-outpost.yml
vendored
18
.github/workflows/ci-outpost.yml
vendored
@@ -21,8 +21,8 @@ jobs:
|
||||
lint-golint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Prepare and generate API
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
|
||||
uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5000s --verbose
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup authentik env
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -145,13 +145,13 @@ jobs:
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
12
.github/workflows/ci-web.yml
vendored
12
.github/workflows/ci-web.yml
vendored
@@ -31,8 +31,8 @@ jobs:
|
||||
- command: lit-analyse
|
||||
project: web
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.project }}/package.json
|
||||
cache: "npm"
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -76,8 +76,8 @@ jobs:
|
||||
- ci-web-mark
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
6
.github/workflows/gen-image-compress.yml
vendored
6
.github/workflows/gen-image-compress.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||
id: cpr
|
||||
with:
|
||||
|
||||
@@ -16,17 +16,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: uv run ak update_webauthn_mds
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/gh-cherry-pick.yml
vendored
4
.github/workflows/gh-cherry-pick.yml
vendored
@@ -10,14 +10,14 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
if: ${{ env.GH_APP_ID != '' }}
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
2
.github/workflows/gh-ghcr-retention.yml
vendored
2
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
17
.github/workflows/packages-npm-publish.yml
vendored
17
.github/workflows/packages-npm-publish.yml
vendored
@@ -5,10 +5,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- packages/tsconfig/**
|
||||
- packages/docusaurus-config/**
|
||||
- packages/eslint-config/**
|
||||
- packages/prettier-config/**
|
||||
- packages/docusaurus-config/**
|
||||
- packages/tsconfig/**
|
||||
- packages/esbuild-plugin-live-reload/**
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,28 +24,25 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package:
|
||||
# The order of the `*config` packages should not be changed, as they depend on each other.
|
||||
- packages/tsconfig
|
||||
- packages/docusaurus-config
|
||||
- packages/eslint-config
|
||||
- packages/prettier-config
|
||||
- packages/docusaurus-config
|
||||
- packages/tsconfig
|
||||
- packages/esbuild-plugin-live-reload
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.package }}/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Publish package
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ${{ matrix.package }}
|
||||
|
||||
2
.github/workflows/qa-codeql.yml
vendored
2
.github/workflows/qa-codeql.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
language: ["go", "javascript", "python"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Initialize CodeQL
|
||||
|
||||
2
.github/workflows/qa-semgrep.yml
vendored
2
.github/workflows/qa-semgrep.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
||||
image: semgrep/semgrep
|
||||
if: (github.actor != 'dependabot[bot]')
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- run: semgrep ci
|
||||
|
||||
10
.github/workflows/release-branch-off.yml
vendored
10
.github/workflows/release-branch-off.yml
vendored
@@ -29,12 +29,12 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: main
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -57,12 +57,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: release-bump-${{ inputs.next_version }}
|
||||
|
||||
2
.github/workflows/release-next-branch.yml
vendored
2
.github/workflows/release-next-branch.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: main
|
||||
- run: |
|
||||
|
||||
22
.github/workflows/release-publish.yml
vendored
22
.github/workflows/release-publish.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
- radius
|
||||
- rac
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Set up QEMU
|
||||
@@ -146,11 +146,11 @@ jobs:
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
export CGO_ENABLED=0
|
||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- name: Upload binaries to release
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
@@ -186,8 +186,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5
|
||||
with:
|
||||
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Run test suite in final docker images
|
||||
run: |
|
||||
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
|
||||
24
.github/workflows/release-tag.yml
vendored
24
.github/workflows/release-tag.yml
vendored
@@ -49,12 +49,8 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
@@ -65,7 +61,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -74,7 +70,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -93,7 +89,7 @@ jobs:
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
@@ -112,7 +108,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -122,7 +118,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -134,7 +130,7 @@ jobs:
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
@@ -154,7 +150,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -164,7 +160,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -189,7 +185,7 @@ jobs:
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
|
||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -21,15 +21,15 @@ jobs:
|
||||
steps:
|
||||
- id: generate_token
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
make web-check-compile
|
||||
- name: Create Pull Request
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: extract-compile-backend-translation
|
||||
|
||||
41
.github/workflows/translation-rename.yml
vendored
Normal file
41
.github/workflows/translation-rename.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# Rename transifex pull requests to have a correct naming
|
||||
# Also enables auto squash-merge
|
||||
name: Translation - Auto-rename Transifex PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
# Permission to rename PR
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
rename_pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Get current title
|
||||
id: title
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
run: |
|
||||
title=$(gh pr view ${{ github.event.pull_request.number }} --json "title" -q ".title")
|
||||
echo "title=${title}" >> "$GITHUB_OUTPUT"
|
||||
- name: Rename
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
|
||||
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
pull-request-number: ${{ github.event.pull_request.number }}
|
||||
merge-method: squash
|
||||
@@ -26,10 +26,6 @@ website/api/reference
|
||||
node_modules
|
||||
coverage
|
||||
|
||||
## Vendored files
|
||||
vendored
|
||||
*.min.js
|
||||
|
||||
## Configs
|
||||
*.log
|
||||
*.yaml
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -11,9 +11,6 @@
|
||||
"[jsonc]": {
|
||||
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
|
||||
},
|
||||
"[xml]": {
|
||||
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
|
||||
},
|
||||
"todo-tree.tree.showCountsInTree": true,
|
||||
"todo-tree.tree.showBadges": true,
|
||||
"yaml.customTags": [
|
||||
|
||||
@@ -28,10 +28,6 @@ packages/django-channels-postgres @goauthentik/backend
|
||||
packages/django-postgres-cache @goauthentik/backend
|
||||
packages/django-dramatiq-postgres @goauthentik/backend
|
||||
# Web packages
|
||||
package.json @goauthentik/frontend
|
||||
package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/frontend
|
||||
packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.4-trixie@sha256:27e1c927a07ed2c7295d39941d6d881424739dbde9ae3055d0d3013699ed35e8 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a382b1f4b18355df5be270ade679ff1d AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
@@ -163,11 +163,10 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
||||
mkdir -p /certs /data /media /blueprints && \
|
||||
ln -s /media /data/media && \
|
||||
mkdir -p /certs /media /blueprints && \
|
||||
mkdir -p /authentik/.ssh && \
|
||||
mkdir -p /ak-root && \
|
||||
chown authentik:authentik /certs /data /data/media /media /authentik/.ssh /ak-root
|
||||
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
|
||||
|
||||
COPY ./authentik/ /authentik
|
||||
COPY ./pyproject.toml /
|
||||
|
||||
49
Makefile
49
Makefile
@@ -9,13 +9,6 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
|
||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
||||
DOCKER_IMAGE ?= "authentik:test"
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
SED_INPLACE = sed -i ''
|
||||
else
|
||||
SED_INPLACE = sed -i
|
||||
endif
|
||||
|
||||
GEN_API_TS = gen-ts-api
|
||||
GEN_API_PY = gen-py-api
|
||||
GEN_API_GO = gen-go-api
|
||||
@@ -24,20 +17,21 @@ pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/
|
||||
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
|
||||
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
|
||||
|
||||
UNAME := $(shell uname)
|
||||
|
||||
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
|
||||
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
|
||||
# These functions are only evaluated when called in specific targets
|
||||
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
|
||||
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
|
||||
|
||||
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
|
||||
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
|
||||
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
||||
|
||||
KRB_PATH =
|
||||
|
||||
ifneq ($(KRB5_EXISTS),)
|
||||
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
|
||||
ifeq ($(UNAME), Darwin)
|
||||
# Only add for brew users who installed libxmlsec1
|
||||
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
|
||||
ifdef BREW_EXISTS
|
||||
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
|
||||
ifdef LIBXML2_EXISTS
|
||||
BREW_LDFLAGS := -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
|
||||
BREW_CPPFLAGS := -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
|
||||
BREW_PKG_CONFIG_PATH := $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
all: lint-fix lint gen web test ## Lint, build, and test everything
|
||||
@@ -56,7 +50,7 @@ go-test:
|
||||
go test -timeout 0 -v -race -cover ./...
|
||||
|
||||
test: ## Run the server tests and produce a coverage report (locally)
|
||||
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
||||
uv run coverage run manage.py test --keepdb authentik
|
||||
uv run coverage html
|
||||
uv run coverage report
|
||||
|
||||
@@ -72,11 +66,11 @@ lint: ## Lint the python and golang sources
|
||||
golangci-lint run -v
|
||||
|
||||
core-install:
|
||||
ifneq ($(LIBXML2_EXISTS),)
|
||||
ifdef LIBXML2_EXISTS
|
||||
# Clear cache to ensure fresh compilation
|
||||
uv cache clean
|
||||
# Force compilation from source for lxml and xmlsec with correct environment
|
||||
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
||||
LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
||||
else
|
||||
uv sync --frozen
|
||||
endif
|
||||
@@ -126,8 +120,8 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
||||
ifndef version
|
||||
$(error Usage: make bump version=20xx.xx.xx )
|
||||
endif
|
||||
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
npm version --no-git-tag-version --allow-same-version $(version)
|
||||
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
|
||||
@@ -162,8 +156,8 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
|
||||
/local/schema-old.yml \
|
||||
/local/schema.yml
|
||||
rm schema-old.yml
|
||||
$(SED_INPLACE) 's/{/{/g' diff.md
|
||||
$(SED_INPLACE) 's/}/}/g' diff.md
|
||||
sed -i 's/{/{/g' diff.md
|
||||
sed -i 's/}/}/g' diff.md
|
||||
npx prettier --write diff.md
|
||||
|
||||
gen-clean-ts: ## Remove generated API client for TypeScript
|
||||
@@ -203,12 +197,11 @@ endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||
|
||||
gen-client-go: ## Build and install the authentik API for Golang
|
||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||
mkdir -p ${PWD}/${GEN_API_GO}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_GO} && git reset --hard
|
||||
cd ${PWD}/${GEN_API_GO} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.2.0-rc1"
|
||||
VERSION = "2025.12.0-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
||||
|
||||
def get_version_latest(self, _) -> str:
|
||||
"""Get latest version from cache"""
|
||||
if get_current_tenant().schema_name != get_public_schema_name():
|
||||
if get_current_tenant().schema_name == get_public_schema_name():
|
||||
return authentik_version()
|
||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||
if not version_in_cache: # pragma: no cover
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import mimetypes
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.admin.files.fields import FileField as AkFileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileApiUsage
|
||||
from authentik.admin.files.validation import validate_upload_file_name
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
|
||||
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
|
||||
def get_mime_from_filename(filename: str) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return mime_type or "application/octet-stream"
|
||||
|
||||
|
||||
class FileView(APIView):
|
||||
pagination_class = None
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def get_permissions(self):
|
||||
return [
|
||||
HasPermission(
|
||||
"authentik_rbac.view_media_files"
|
||||
if self.request.method in SAFE_METHODS
|
||||
else "authentik_rbac.manage_media_files"
|
||||
)()
|
||||
]
|
||||
|
||||
class FileListParameters(PassiveSerializer):
|
||||
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
|
||||
search = CharField(required=False)
|
||||
manageable_only = BooleanField(required=False, default=False)
|
||||
|
||||
class FileListSerializer(PassiveSerializer):
|
||||
name = CharField()
|
||||
mime_type = CharField()
|
||||
url = CharField()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileListParameters],
|
||||
responses={200: FileListSerializer(many=True)},
|
||||
)
|
||||
@validate(FileListParameters, location="query")
|
||||
def get(self, request: Request, query: FileListParameters) -> Response:
|
||||
"""List files from storage backend."""
|
||||
params = query.validated_data
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Invalid usage parameter provided: {params.get('usage')}"
|
||||
) from exc
|
||||
|
||||
# Backend is source of truth - list all files from storage
|
||||
manager = get_file_manager(usage)
|
||||
files = manager.list_files(manageable_only=params.get("manageable_only", False))
|
||||
search_query = params.get("search", "")
|
||||
if search_query:
|
||||
files = filter(lambda file: search_query in file.lower(), files)
|
||||
files = [
|
||||
FileView.FileListSerializer(
|
||||
data={
|
||||
"name": file,
|
||||
"url": manager.file_url(file),
|
||||
"mime_type": get_mime_from_filename(file),
|
||||
}
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
for file in files:
|
||||
file.is_valid(raise_exception=True)
|
||||
|
||||
return Response([file.data for file in files])
|
||||
|
||||
class FileUploadSerializer(PassiveSerializer):
|
||||
file = FileField(required=True)
|
||||
name = CharField(required=False, allow_blank=True)
|
||||
usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
|
||||
|
||||
@extend_schema(
|
||||
request=FileUploadSerializer,
|
||||
responses={200: None},
|
||||
)
|
||||
@validate(FileUploadSerializer)
|
||||
def post(self, request: Request, body: FileUploadSerializer) -> Response:
|
||||
"""Upload file to storage backend."""
|
||||
file = body.validated_data["file"]
|
||||
name = body.validated_data.get("name", "").strip()
|
||||
usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
|
||||
|
||||
# Validate file size and type
|
||||
if file.size > MAX_FILE_SIZE_BYTES:
|
||||
raise ValidationError(
|
||||
{
|
||||
"file": [
|
||||
_(
|
||||
f"File size ({file.size}B) exceeds maximum allowed "
|
||||
f"size ({MAX_FILE_SIZE_BYTES}B)."
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(usage_value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
|
||||
|
||||
# Use original filename
|
||||
if not name:
|
||||
name = file.name
|
||||
|
||||
# Sanitize path to prevent directory traversal
|
||||
validate_upload_file_name(name, ValidationError)
|
||||
|
||||
manager = get_file_manager(usage)
|
||||
|
||||
# Check if file already exists
|
||||
if manager.file_exists(name):
|
||||
raise ValidationError({"name": ["A file with this name already exists."]})
|
||||
|
||||
# Save to backend
|
||||
with manager.save_file_stream(name) as f:
|
||||
f.write(file.read())
|
||||
|
||||
Event.new(
|
||||
EventAction.MODEL_CREATED,
|
||||
model={
|
||||
"app": "authentik_admin_files",
|
||||
"model_name": "File",
|
||||
"pk": name,
|
||||
"name": name,
|
||||
"usage": usage.value,
|
||||
"mime_type": get_mime_from_filename(name),
|
||||
},
|
||||
).from_http(request)
|
||||
|
||||
return Response()
|
||||
|
||||
class FileDeleteParameters(PassiveSerializer):
|
||||
name = CharField()
|
||||
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileDeleteParameters],
|
||||
responses={200: None},
|
||||
)
|
||||
@validate(FileDeleteParameters, location="query")
|
||||
def delete(self, request: Request, query: FileDeleteParameters) -> Response:
|
||||
"""Delete file from storage backend."""
|
||||
params = query.validated_data
|
||||
|
||||
validate_upload_file_name(params.get("name", ""), ValidationError)
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Invalid usage parameter provided: {params.get('usage')}"
|
||||
) from exc
|
||||
|
||||
manager = get_file_manager(usage)
|
||||
|
||||
# Delete from backend
|
||||
manager.delete_file(params.get("name"))
|
||||
|
||||
# Audit log for file deletion
|
||||
Event.new(
|
||||
EventAction.MODEL_DELETED,
|
||||
model={
|
||||
"app": "authentik_admin_files",
|
||||
"model_name": "File",
|
||||
"pk": params.get("name"),
|
||||
"name": params.get("name"),
|
||||
"usage": usage.value,
|
||||
},
|
||||
).from_http(request)
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
class FileUsedByView(APIView):
|
||||
pagination_class = None
|
||||
|
||||
def get_permissions(self):
|
||||
return [
|
||||
HasPermission(
|
||||
"authentik_rbac.view_media_files"
|
||||
if self.request.method in SAFE_METHODS
|
||||
else "authentik_rbac.manage_media_files"
|
||||
)()
|
||||
]
|
||||
|
||||
class FileUsedByParameters(PassiveSerializer):
|
||||
name = CharField()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileUsedByParameters],
|
||||
responses={200: UsedBySerializer(many=True)},
|
||||
)
|
||||
@validate(FileUsedByParameters, location="query")
|
||||
def get(self, request: Request, query: FileUsedByParameters) -> Response:
|
||||
params = query.validated_data
|
||||
|
||||
models_and_fields = {}
|
||||
for app in get_apps():
|
||||
for model in app.get_models():
|
||||
if model._meta.abstract:
|
||||
continue
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, AkFileField):
|
||||
models_and_fields.setdefault(model, []).append(field.name)
|
||||
|
||||
used_by = []
|
||||
|
||||
for model, fields in models_and_fields.items():
|
||||
app = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{field: params.get("name")})
|
||||
|
||||
objs = get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", model.objects.all()
|
||||
)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
data={
|
||||
"app": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
"pk": str(obj.pk),
|
||||
"name": str(obj),
|
||||
"action": DeleteAction.LEFT_DANGLING,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
used_by.append(serializer.data)
|
||||
|
||||
return Response(used_by)
|
||||
@@ -1,8 +0,0 @@
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
name = "authentik.admin.files"
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
@@ -1,162 +0,0 @@
|
||||
from collections.abc import Callable, Generator, Iterator
|
||||
from typing import cast
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http.request import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
CACHE_PREFIX = "goauthentik.io/admin/files"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class Backend:
|
||||
"""
|
||||
Base class for file storage backends.
|
||||
|
||||
Class attributes:
|
||||
allowed_usages: List of usages that can be used with this backend
|
||||
"""
|
||||
|
||||
allowed_usages: list[FileUsage]
|
||||
|
||||
def __init__(self, usage: FileUsage):
|
||||
"""
|
||||
Initialize backend for the given usage type.
|
||||
|
||||
Args:
|
||||
usage: FileUsage type enum value
|
||||
"""
|
||||
self.usage = usage
|
||||
LOGGER.debug(
|
||||
"Initializing storage backend",
|
||||
backend=self.__class__.__name__,
|
||||
usage=usage.value,
|
||||
)
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""
|
||||
Check if this backend can handle the given file path.
|
||||
|
||||
Args:
|
||||
name: File path to check
|
||||
|
||||
Returns:
|
||||
True if this backend supports this file path
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""
|
||||
List all files stored in this backend.
|
||||
|
||||
Yields:
|
||||
Relative file paths
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
request: Optional Django HttpRequest for fully qualifed URL building
|
||||
use_cache: whether to retrieve the URL from cache
|
||||
|
||||
Returns:
|
||||
URL to access the file (may be relative or absolute depending on backend)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ManageableBackend(Backend):
|
||||
"""
|
||||
Base class for manageable file storage backends.
|
||||
|
||||
Class attributes:
|
||||
name: Canonical name of the storage backend, for use in configuration.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
"""
|
||||
Whether this backend can actually be used for management.
|
||||
|
||||
Used only for management check, not for created the backend
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""
|
||||
Save file content to storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
content: File content as bytes
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""
|
||||
Context manager for streaming file writes.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
Context manager that yields a writable file-like object
|
||||
|
||||
FileUsage:
|
||||
with backend.save_file_stream("output.csv") as f:
|
||||
f.write(b"data...")
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""
|
||||
Delete file from storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _cache_get_or_set(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None,
|
||||
default: Callable[[str, HttpRequest | None], str],
|
||||
timeout: int,
|
||||
) -> str:
|
||||
timeout_ignore = 60
|
||||
timeout = int(timeout * 0.67)
|
||||
if timeout < timeout_ignore:
|
||||
timeout = 0
|
||||
|
||||
request_key = "None"
|
||||
if request is not None:
|
||||
request_key = f"{request.build_absolute_uri('/')}"
|
||||
cache_key = f"{CACHE_PREFIX}/{self.name}/{self.usage}/{request_key}/{name}"
|
||||
|
||||
return cast(str, cache.get_or_set(cache_key, lambda: default(name, request), timeout))
|
||||
@@ -1,126 +0,0 @@
|
||||
import os
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
|
||||
class FileBackend(ManageableBackend):
|
||||
"""Local filesystem backend for file storage.
|
||||
|
||||
Stores files in a local directory structure:
|
||||
- Path: {base_dir}/{usage}/{schema}/{filename}
|
||||
- Supports full file management (upload, delete, list)
|
||||
- Used when storage.backend=file (default)
|
||||
"""
|
||||
|
||||
name = "file"
|
||||
allowed_usages = list(FileUsage) # All usages
|
||||
|
||||
@property
|
||||
def _base_dir(self) -> Path:
|
||||
return Path(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.path",
|
||||
CONFIG.get(f"storage.{self.name}.path", "./data"),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
"""Path structure: {base_dir}/{usage}/{schema}"""
|
||||
return self._base_dir / self.usage.value / connection.schema_name
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
return (
|
||||
self.base_path.exists()
|
||||
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
|
||||
or (settings.DEBUG or settings.TEST)
|
||||
)
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""We support all files"""
|
||||
return True
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all files returning relative paths from base_path."""
|
||||
for root, _, files in os.walk(self.base_path):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
rel_path = full_path.relative_to(self.base_path)
|
||||
yield str(rel_path)
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""Get URL for accessing the file."""
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
if use_cache:
|
||||
timeout = int(expires_in.total_seconds())
|
||||
return self._cache_get_or_set(name, request, _file_url, timeout)
|
||||
else:
|
||||
return _file_url(name, request)
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w+b") as f:
|
||||
f.write(content)
|
||||
|
||||
@contextmanager
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""Context manager for streaming file writes to local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
yield f
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""Delete file from local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""Check if a file exists."""
|
||||
path = self.base_path / Path(name)
|
||||
return path.exists()
|
||||
@@ -1,48 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
EXTERNAL_URL_SCHEMES = ["http:", "https://"]
|
||||
FONT_AWESOME_SCHEME = "fa://"
|
||||
|
||||
|
||||
class PassthroughBackend(Backend):
|
||||
"""Passthrough backend for external URLs and special schemes.
|
||||
|
||||
Handles external resources that aren't stored in authentik:
|
||||
- Font Awesome icons (fa://...)
|
||||
- HTTP/HTTPS URLs (http://..., https://...)
|
||||
|
||||
Files that are "managed" by this backend are just passed through as-is.
|
||||
No upload, delete, or listing operations are supported.
|
||||
Only accessible through resolve_file_url when an external URL is detected.
|
||||
"""
|
||||
|
||||
allowed_usages = [FileUsage.MEDIA]
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""Check if file path is an external URL or Font Awesome icon."""
|
||||
if name.startswith(FONT_AWESOME_SCHEME):
|
||||
return True
|
||||
|
||||
for scheme in EXTERNAL_URL_SCHEMES:
|
||||
if name.startswith(scheme):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""External files cannot be listed."""
|
||||
yield from []
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""Return the URL as-is for passthrough files."""
|
||||
return name
|
||||
@@ -1,226 +0,0 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from django.db import connection
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
|
||||
class S3Backend(ManageableBackend):
|
||||
"""S3-compatible object storage backend.
|
||||
|
||||
Stores files in s3-compatible storage:
|
||||
- Key prefix: {usage}/{schema}/{filename}
|
||||
- Supports full file management (upload, delete, list)
|
||||
- Generates presigned URLs for file access
|
||||
- Used when storage.backend=s3
|
||||
"""
|
||||
|
||||
allowed_usages = list(FileUsage) # All usages
|
||||
name = "s3"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._config = {}
|
||||
self._session = None
|
||||
|
||||
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
|
||||
unset = object()
|
||||
current = self._config.get(key, unset)
|
||||
refreshed = CONFIG.refresh(
|
||||
f"storage.{self.usage.value}.{self.name}.{key}",
|
||||
CONFIG.refresh(f"storage.{self.name}.{key}", default),
|
||||
)
|
||||
if current is unset:
|
||||
current = refreshed
|
||||
self._config[key] = refreshed
|
||||
return (refreshed, current != refreshed)
|
||||
|
||||
@property
|
||||
def base_path(self) -> str:
|
||||
"""S3 key prefix: {usage}/{schema}/"""
|
||||
return f"{self.usage.value}/{connection.schema_name}"
|
||||
|
||||
@property
|
||||
def bucket_name(self) -> str:
|
||||
return CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.bucket_name",
|
||||
CONFIG.get(f"storage.{self.name}.bucket_name"),
|
||||
)
|
||||
|
||||
@property
|
||||
def session(self) -> boto3.Session:
|
||||
"""Create boto3 session with configured credentials."""
|
||||
session_profile, session_profile_r = self._get_config("session_profile", None)
|
||||
if session_profile is not None:
|
||||
if session_profile_r or self._session is None:
|
||||
self._session = boto3.Session(profile_name=session_profile)
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
else:
|
||||
access_key, access_key_r = self._get_config("access_key", None)
|
||||
secret_key, secret_key_r = self._get_config("secret_key", None)
|
||||
session_token, session_token_r = self._get_config("session_token", None)
|
||||
if access_key_r or secret_key_r or session_token_r or self._session is None:
|
||||
self._session = boto3.Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
aws_session_token=session_token,
|
||||
)
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Create S3 client with configured endpoint and region."""
|
||||
endpoint_url = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.endpoint",
|
||||
CONFIG.get(f"storage.{self.name}.endpoint", None),
|
||||
)
|
||||
use_ssl = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.use_ssl",
|
||||
CONFIG.get(f"storage.{self.name}.use_ssl", True),
|
||||
)
|
||||
region_name = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.region",
|
||||
CONFIG.get(f"storage.{self.name}.region", None),
|
||||
)
|
||||
addressing_style = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.addressing_style",
|
||||
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
|
||||
)
|
||||
|
||||
return self.session.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint_url,
|
||||
use_ssl=use_ssl,
|
||||
region_name=region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
|
||||
)
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""We support all files"""
|
||||
return True
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all files returning relative paths from base_path."""
|
||||
paginator = self.client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=f"{self.base_path}/")
|
||||
|
||||
for page in pages:
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
# Remove base path prefix to get relative path
|
||||
rel_path = key.removeprefix(f"{self.base_path}/")
|
||||
if rel_path: # Skip if it's just the directory itself
|
||||
yield rel_path
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""Generate presigned URL for file access."""
|
||||
use_https = CONFIG.get_bool(
|
||||
f"storage.{self.usage.value}.{self.name}.secure_urls",
|
||||
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
|
||||
)
|
||||
|
||||
expires_in = int(
|
||||
timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
).total_seconds()
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
|
||||
|
||||
return url
|
||||
|
||||
if use_cache:
|
||||
return self._cache_get_or_set(name, request, _file_url, expires_in)
|
||||
else:
|
||||
return _file_url(name, request)
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to S3."""
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
Body=content,
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""Context manager for streaming file writes to S3."""
|
||||
# Keep files in memory up to 5 MB
|
||||
with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file:
|
||||
yield file
|
||||
file.seek(0)
|
||||
self.client.upload_fileobj(
|
||||
Fileobj=file,
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
ExtraArgs={
|
||||
"ACL": "private",
|
||||
},
|
||||
)
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""Delete file from S3."""
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
)
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""Check if a file exists in S3."""
|
||||
try:
|
||||
self.client.head_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
@@ -1,58 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
STATIC_ASSETS_BASE_DIR = Path("web/dist")
|
||||
STATIC_ASSETS_DIRS = [Path(p) for p in ("assets/icons", "assets/images")]
|
||||
STATIC_ASSETS_SOURCES_DIR = Path("web/authentik/sources")
|
||||
STATIC_FILE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg"]
|
||||
STATIC_PATH_PREFIX = "/static"
|
||||
|
||||
|
||||
class StaticBackend(Backend):
|
||||
"""Read-only backend for static files from web/dist/assets.
|
||||
|
||||
- Used for serving built-in static assets like icons and images.
|
||||
- Files cannot be uploaded or deleted through this backend.
|
||||
- Only accessible through resolve_file_url when a static path is detected.
|
||||
"""
|
||||
|
||||
allowed_usages = [FileUsage.MEDIA]
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""Check if file path is a static path."""
|
||||
return name.startswith(STATIC_PATH_PREFIX)
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all static files."""
|
||||
# List built-in source icons
|
||||
if STATIC_ASSETS_SOURCES_DIR.exists():
|
||||
for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir():
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}"
|
||||
|
||||
# List other static assets
|
||||
for dir in STATIC_ASSETS_DIRS:
|
||||
dist_dir = STATIC_ASSETS_BASE_DIR / dir
|
||||
if dist_dir.exists():
|
||||
for file_path in dist_dir.rglob("*"):
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""Get URL for static file."""
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
url = f"{prefix}{name}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
@@ -1,167 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.file import FileBackend
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
"""Test FileBackend class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
super().setUp()
|
||||
self.backend = FileBackend(FileUsage.MEDIA)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that FileBackend supports all usage types"""
|
||||
self.assertEqual(self.backend.allowed_usages, list(FileUsage))
|
||||
|
||||
def test_base_path(self):
|
||||
"""Test base_path property constructs correct path"""
|
||||
base_path = self.backend.base_path
|
||||
|
||||
expected = Path(self.media_backend_path) / "media" / "public"
|
||||
self.assertEqual(base_path, expected)
|
||||
|
||||
def test_base_path_reports_usage(self):
|
||||
"""Test base_path with reports usage"""
|
||||
backend = FileBackend(FileUsage.REPORTS)
|
||||
base_path = backend.base_path
|
||||
|
||||
expected = Path(self.reports_backend_path) / "reports" / "public"
|
||||
self.assertEqual(base_path, expected)
|
||||
|
||||
def test_list_files_empty_directory(self):
|
||||
"""Test list_files returns empty when directory is empty"""
|
||||
# Create the directory but keep it empty
|
||||
self.backend.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_list_files_with_files(self):
|
||||
"""Test list_files returns all files in directory"""
|
||||
base_path = self.backend.base_path
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create some test files
|
||||
(base_path / "file1.txt").write_text("content1")
|
||||
(base_path / "file2.png").write_text("content2")
|
||||
(base_path / "subdir").mkdir()
|
||||
(base_path / "subdir" / "file3.csv").write_text("content3")
|
||||
|
||||
files = sorted(list(self.backend.list_files()))
|
||||
expected = sorted(["file1.txt", "file2.png", "subdir/file3.csv"])
|
||||
self.assertEqual(files, expected)
|
||||
|
||||
def test_list_files_nonexistent_directory(self):
|
||||
"""Test list_files returns empty when directory doesn't exist"""
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_save_file(self):
|
||||
content = b"test file content"
|
||||
file_name = "test.txt"
|
||||
|
||||
self.backend.save_file(file_name, content)
|
||||
|
||||
# Verify file was created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_creates_subdirectories(self):
|
||||
"""Test save_file creates parent directories as needed"""
|
||||
content = b"nested file content"
|
||||
file_name = "subdir1/subdir2/nested.txt"
|
||||
|
||||
self.backend.save_file(file_name, content)
|
||||
|
||||
# Verify file and directories were created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_stream(self):
|
||||
"""Test save_file_stream context manager writes file correctly"""
|
||||
content = b"streamed content"
|
||||
file_name = "stream_test.txt"
|
||||
|
||||
with self.backend.save_file_stream(file_name) as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify file was created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_stream_creates_subdirectories(self):
|
||||
"""Test save_file_stream creates parent directories as needed"""
|
||||
content = b"nested stream content"
|
||||
file_name = "dir1/dir2/stream.bin"
|
||||
|
||||
with self.backend.save_file_stream(file_name) as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify file and directories were created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_delete_file(self):
|
||||
"""Test delete_file removes existing file"""
|
||||
file_name = "to_delete.txt"
|
||||
|
||||
# Create file first
|
||||
self.backend.save_file(file_name, b"content")
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
|
||||
# Delete it
|
||||
self.backend.delete_file(file_name)
|
||||
self.assertFalse(file_path.exists())
|
||||
|
||||
def test_delete_file_nonexistent(self):
|
||||
"""Test delete_file handles nonexistent file gracefully"""
|
||||
file_name = "does_not_exist.txt"
|
||||
self.backend.delete_file(file_name)
|
||||
|
||||
def test_file_url(self):
|
||||
"""Test file_url generates correct URL"""
|
||||
file_name = "icon.png"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/files/media/public/icon.png"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
@CONFIG.patch("web.path", "/authentik/")
|
||||
def test_file_url_with_prefix(self):
|
||||
"""Test file_url with web path prefix"""
|
||||
file_name = "logo.svg"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/authentik/files/media/public/logo.svg"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
def test_file_url_nested_path(self):
|
||||
"""Test file_url with nested file path"""
|
||||
file_name = "path/to/file.png"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/files/media/public/path/to/file.png"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
def test_file_exists_true(self):
|
||||
"""Test file_exists returns True for existing file"""
|
||||
file_name = "exists.txt"
|
||||
self.backend.base_path.mkdir(parents=True, exist_ok=True)
|
||||
(self.backend.base_path / file_name).touch()
|
||||
self.assertTrue(self.backend.file_exists(file_name))
|
||||
|
||||
def test_file_exists_false(self):
|
||||
"""Test file_exists returns False for nonexistent file"""
|
||||
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Test passthrough backend"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
|
||||
class TestPassthroughBackend(TestCase):
|
||||
"""Test PassthroughBackend class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.backend = PassthroughBackend(FileUsage.MEDIA)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that PassthroughBackend only supports MEDIA usage"""
|
||||
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
|
||||
|
||||
def test_supports_file_path_font_awesome(self):
|
||||
"""Test supports_file_path returns True for Font Awesome icons"""
|
||||
self.assertTrue(self.backend.supports_file("fa://user"))
|
||||
self.assertTrue(self.backend.supports_file("fa://home"))
|
||||
self.assertTrue(self.backend.supports_file("fa://shield"))
|
||||
|
||||
def test_supports_file_path_http(self):
|
||||
"""Test supports_file_path returns True for HTTP URLs"""
|
||||
self.assertTrue(self.backend.supports_file("http://example.com/icon.png"))
|
||||
self.assertTrue(self.backend.supports_file("http://cdn.example.com/logo.svg"))
|
||||
|
||||
def test_supports_file_path_https(self):
|
||||
"""Test supports_file_path returns True for HTTPS URLs"""
|
||||
self.assertTrue(self.backend.supports_file("https://example.com/icon.png"))
|
||||
self.assertTrue(self.backend.supports_file("https://cdn.example.com/logo.svg"))
|
||||
|
||||
def test_supports_file_path_false(self):
|
||||
"""Test supports_file_path returns False for regular paths"""
|
||||
self.assertFalse(self.backend.supports_file("icon.png"))
|
||||
self.assertFalse(self.backend.supports_file("/static/icon.png"))
|
||||
self.assertFalse(self.backend.supports_file("media/logo.svg"))
|
||||
self.assertFalse(self.backend.supports_file(""))
|
||||
|
||||
def test_supports_file_path_invalid_scheme(self):
|
||||
"""Test supports_file_path returns False for invalid schemes"""
|
||||
self.assertFalse(self.backend.supports_file("ftp://example.com/file.png"))
|
||||
self.assertFalse(self.backend.supports_file("file:///path/to/file.png"))
|
||||
self.assertFalse(self.backend.supports_file("data:image/png;base64,abc123"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files returns empty generator"""
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_file_url(self):
|
||||
"""Test file_url returns the URL as-is"""
|
||||
url = "https://example.com/icon.png"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
|
||||
def test_file_url_font_awesome(self):
|
||||
"""Test file_url returns Font Awesome URL as-is"""
|
||||
url = "fa://user"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
|
||||
def test_file_url_http(self):
|
||||
"""Test file_url returns HTTP URL as-is"""
|
||||
url = "http://cdn.example.com/logo.svg"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
@@ -1,112 +0,0 @@
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_base_path(self):
|
||||
"""Test base_path property generates correct S3 key prefix"""
|
||||
expected = "media/public"
|
||||
self.assertEqual(self.media_s3_backend.base_path, expected)
|
||||
|
||||
def test_supports_file_path_s3(self):
|
||||
"""Test supports_file_path returns True for s3 backend"""
|
||||
self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
|
||||
self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files returns relative paths"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/file1.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/other/file1.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
files = list(self.media_s3_backend.list_files())
|
||||
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertIn("file1.png", files)
|
||||
|
||||
def test_list_files_empty(self):
|
||||
"""Test list_files with no files"""
|
||||
files = list(self.media_s3_backend.list_files())
|
||||
|
||||
self.assertEqual(len(files), 0)
|
||||
|
||||
def test_save_file(self):
|
||||
"""Test save_file uploads to S3"""
|
||||
content = b"test file content"
|
||||
self.media_s3_backend.save_file("test.png", content)
|
||||
|
||||
def test_save_file_stream(self):
|
||||
"""Test save_file_stream uploads to S3 using context manager"""
|
||||
with self.media_s3_backend.save_file_stream("test.csv") as f:
|
||||
f.write(b"header1,header2\n")
|
||||
f.write(b"value1,value2\n")
|
||||
|
||||
def test_delete_file(self):
|
||||
"""Test delete_file removes from S3"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
self.media_s3_backend.delete_file("test.png")
|
||||
|
||||
@CONFIG.patch("storage.s3.secure_urls", True)
|
||||
@CONFIG.patch("storage.s3.custom_domain", None)
|
||||
def test_file_url_basic(self):
|
||||
"""Test file_url generates presigned URL with AWS signature format"""
|
||||
url = self.media_s3_backend.file_url("test.png")
|
||||
|
||||
self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
|
||||
self.assertIn("X-Amz-Signature=", url)
|
||||
self.assertIn("test.png", url)
|
||||
|
||||
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
|
||||
def test_file_exists_true(self):
|
||||
"""Test file_exists returns True for existing file"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
exists = self.media_s3_backend.file_exists("test.png")
|
||||
|
||||
self.assertTrue(exists)
|
||||
|
||||
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
|
||||
def test_file_exists_false(self):
|
||||
"""Test file_exists returns False for non-existent file"""
|
||||
exists = self.media_s3_backend.file_exists("nonexistent.png")
|
||||
|
||||
self.assertFalse(exists)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that S3Backend supports all usage types"""
|
||||
self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
|
||||
|
||||
def test_reports_usage(self):
|
||||
"""Test S3Backend with REPORTS usage"""
|
||||
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
|
||||
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
|
||||
@@ -1,42 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
|
||||
class TestStaticBackend(TestCase):
|
||||
"""Test Static backend functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.usage = FileUsage.MEDIA
|
||||
self.backend = StaticBackend(self.usage)
|
||||
|
||||
def test_init(self):
|
||||
"""Test StaticBackend initialization"""
|
||||
self.assertEqual(self.backend.usage, self.usage)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that StaticBackend only supports MEDIA usage"""
|
||||
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
|
||||
|
||||
def test_supports_file_path_static_prefix(self):
|
||||
"""Test supports_file_path returns True for /static prefix"""
|
||||
self.assertTrue(self.backend.supports_file("/static/assets/icons/test.svg"))
|
||||
self.assertTrue(self.backend.supports_file("/static/authentik/sources/icon.png"))
|
||||
|
||||
def test_supports_file_path_not_static(self):
|
||||
"""Test supports_file_path returns False for non-static paths"""
|
||||
self.assertFalse(self.backend.supports_file("web/dist/assets/icons/test.svg"))
|
||||
self.assertFalse(self.backend.supports_file("web/dist/assets/images/logo.png"))
|
||||
self.assertFalse(self.backend.supports_file("media/public/test.png"))
|
||||
self.assertFalse(self.backend.supports_file("/media/test.svg"))
|
||||
self.assertFalse(self.backend.supports_file("test.jpg"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files includes expected files"""
|
||||
files = list(self.backend.list_files())
|
||||
|
||||
self.assertIn("/static/authentik/sources/ldap.png", files)
|
||||
self.assertIn("/static/authentik/sources/openidconnect.svg", files)
|
||||
self.assertIn("/static/authentik/sources/saml.png", files)
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
from authentik.admin.files.validation import validate_file_name
|
||||
|
||||
|
||||
class FileField(models.TextField):
|
||||
default_validators = [validate_file_name]
|
||||
@@ -1,142 +0,0 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http.request import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.backends.file import FileBackend
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
_FILE_BACKENDS = [
|
||||
StaticBackend,
|
||||
PassthroughBackend,
|
||||
FileBackend,
|
||||
S3Backend,
|
||||
]
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, usage: FileUsage) -> None:
|
||||
management_backend_name = CONFIG.get(
|
||||
f"storage.{usage.value}.backend",
|
||||
CONFIG.get("storage.backend", "file"),
|
||||
)
|
||||
|
||||
self.management_backend = None
|
||||
for backend in _FILE_BACKENDS:
|
||||
if issubclass(backend, ManageableBackend) and backend.name == management_backend_name:
|
||||
self.management_backend = backend(usage)
|
||||
if self.management_backend is None:
|
||||
LOGGER.warning(
|
||||
f"Storage backend configuration for {usage.value} is "
|
||||
f"invalid: {management_backend_name}"
|
||||
)
|
||||
|
||||
self.backends = []
|
||||
for backend in _FILE_BACKENDS:
|
||||
if usage not in backend.allowed_usages:
|
||||
continue
|
||||
if isinstance(self.management_backend, backend):
|
||||
self.backends.append(self.management_backend)
|
||||
elif not issubclass(backend, ManageableBackend):
|
||||
self.backends.append(backend(usage))
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
"""
|
||||
Whether this file manager is able to manage files.
|
||||
"""
|
||||
return self.management_backend is not None and self.management_backend.manageable
|
||||
|
||||
def list_files(self, manageable_only: bool = False) -> Generator[str]:
|
||||
"""
|
||||
List available files.
|
||||
"""
|
||||
for backend in self.backends:
|
||||
if manageable_only and not isinstance(backend, ManageableBackend):
|
||||
continue
|
||||
yield from backend.list_files()
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str | None,
|
||||
request: HttpRequest | Request | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
if isinstance(request, Request):
|
||||
request = request._request
|
||||
|
||||
for backend in self.backends:
|
||||
if backend.supports_file(name):
|
||||
return backend.file_url(name, request)
|
||||
|
||||
LOGGER.warning(f"Could not find file backend for file: {name}")
|
||||
return ""
|
||||
|
||||
def _check_manageable(self) -> None:
|
||||
if not self.manageable:
|
||||
raise ImproperlyConfigured("No file management backend configured.")
|
||||
|
||||
def save_file(self, file_path: str, content: bytes) -> None:
|
||||
"""
|
||||
Save file contents to storage.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.save_file(file_path, content)
|
||||
|
||||
def save_file_stream(self, file_path: str) -> Iterator:
|
||||
"""
|
||||
Context manager for streaming file writes.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
Context manager that yields a writable file-like object
|
||||
|
||||
Usage:
|
||||
with manager.save_file_stream("output.csv") as f:
|
||||
f.write(b"data...")
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.save_file_stream(file_path)
|
||||
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
"""
|
||||
Delete file from storage.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.delete_file(file_path)
|
||||
|
||||
def file_exists(self, file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.file_exists(file_path)
|
||||
|
||||
|
||||
MANAGERS = {usage: FileManager(usage) for usage in list(FileUsage)}
|
||||
|
||||
|
||||
def get_file_manager(usage: FileUsage) -> FileManager:
|
||||
return MANAGERS[usage]
|
||||
@@ -1 +0,0 @@
|
||||
"""authentik files tests"""
|
||||
@@ -1,229 +0,0 @@
|
||||
"""test file api"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.admin.files.api import get_mime_from_filename
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestFileAPI(FileTestFileBackendMixin, TestCase):
|
||||
"""test file api"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_upload_creates_event(self):
|
||||
"""Test that uploading a file creates a FILE_UPLOADED event"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_content = b"test file content"
|
||||
file_name = "test-upload.png"
|
||||
|
||||
# Upload file
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify event was created
|
||||
event = Event.objects.filter(action=EventAction.MODEL_CREATED).first()
|
||||
|
||||
self.assertIsNotNone(event)
|
||||
assert event is not None # nosec
|
||||
self.assertEqual(event.context["model"]["name"], file_name)
|
||||
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
|
||||
self.assertEqual(event.context["model"]["mime_type"], "image/png")
|
||||
|
||||
# Verify user is captured
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertEqual(event.user["pk"], self.user.pk)
|
||||
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_delete_creates_event(self):
|
||||
"""Test that deleting a file creates an event"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "test-delete.png"
|
||||
manager.save_file(file_name, b"test content")
|
||||
|
||||
# Delete file
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify event was created
|
||||
event = Event.objects.filter(action=EventAction.MODEL_DELETED).first()
|
||||
|
||||
self.assertIsNotNone(event)
|
||||
assert event is not None # nosec
|
||||
self.assertEqual(event.context["model"]["name"], file_name)
|
||||
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
|
||||
|
||||
# Verify user is captured
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertEqual(event.user["pk"], self.user.pk)
|
||||
|
||||
def test_list_files_basic(self):
|
||||
"""Test listing files with default parameters"""
|
||||
response = self.client.get(reverse("authentik_api:files"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_list_files_invalid_usage(self):
|
||||
"""Test listing files with invalid usage parameter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"usage": "invalid",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("not a valid choice", str(response.data))
|
||||
|
||||
def test_list_files_with_search(self):
|
||||
"""Test listing files with search query"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"search": "ldap.png",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_list_files_with_manageable_only(self):
|
||||
"""Test listing files with omit parameter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"manageableOnly": "true",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
{
|
||||
"name": "/static/dist/assets/images/flow_background.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_upload_file_with_custom_path(self):
|
||||
"""Test uploading file with custom path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "custom/test"
|
||||
file_content = b"test content"
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(manager.file_exists(file_name))
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_upload_file_duplicate(self):
|
||||
"""Test uploading file that already exists"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "test-file.png"
|
||||
file_content = b"test content"
|
||||
manager.save_file(file_name, file_content)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("already exists", str(response.data))
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_delete_without_name_parameter(self):
|
||||
"""Test delete without name parameter"""
|
||||
response = self.client.delete(reverse("authentik_api:files"))
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("field is required", str(response.data))
|
||||
|
||||
|
||||
class TestGetMimeFromFilename(TestCase):
|
||||
"""Test get_mime_from_filename function"""
|
||||
|
||||
def test_image_png(self):
|
||||
"""Test PNG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
|
||||
|
||||
def test_image_jpeg(self):
|
||||
"""Test JPEG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
|
||||
|
||||
def test_image_svg(self):
|
||||
"""Test SVG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
|
||||
|
||||
def test_text_plain(self):
|
||||
"""Test text file MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
|
||||
|
||||
def test_unknown_extension(self):
|
||||
"""Test unknown extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
|
||||
|
||||
def test_no_extension(self):
|
||||
"""Test no extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Test file service layer"""
|
||||
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import (
|
||||
FileTestFileBackendMixin,
|
||||
FileTestS3BackendMixin,
|
||||
s3_test_server_available,
|
||||
)
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestResolveFileUrlBasic(TestCase):
|
||||
def test_resolve_empty_path(self):
|
||||
"""Test resolving empty file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_resolve_none_path(self):
|
||||
"""Test resolving None file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url(None)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_resolve_font_awesome(self):
|
||||
"""Test resolving Font Awesome icon"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("fa://fa-check")
|
||||
self.assertEqual(result, "fa://fa-check")
|
||||
|
||||
def test_resolve_http_url(self):
|
||||
"""Test resolving HTTP URL"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("http://example.com/icon.png")
|
||||
self.assertEqual(result, "http://example.com/icon.png")
|
||||
|
||||
def test_resolve_https_url(self):
|
||||
"""Test resolving HTTPS URL"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("https://example.com/icon.png")
|
||||
self.assertEqual(result, "https://example.com/icon.png")
|
||||
|
||||
def test_resolve_static_path(self):
|
||||
"""Test resolving static file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("/static/authentik/sources/icon.svg")
|
||||
self.assertEqual(result, "/static/authentik/sources/icon.svg")
|
||||
|
||||
|
||||
class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
def test_resolve_storage_file(self):
|
||||
"""Test resolving uploaded storage file"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png").split("?")[0]
|
||||
self.assertEqual(result, "/files/media/public/test.png")
|
||||
|
||||
def test_resolve_full_static_with_request(self):
|
||||
"""Test resolving static file with request builds absolute URI"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("/static/icon.svg", mock_request)
|
||||
|
||||
self.assertEqual(result, "http://example.com/static/icon.svg")
|
||||
|
||||
def test_resolve_full_file_backend_with_request(self):
|
||||
"""Test resolving FileBackend file with request"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png", mock_request).split("?")[0]
|
||||
|
||||
self.assertEqual(result, "http://example.com/files/media/public/test.png")
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
|
||||
@CONFIG.patch("storage.media.s3.secure_urls", False)
|
||||
def test_resolve_full_s3_backend(self):
|
||||
"""Test resolving S3Backend returns presigned URL as-is"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png", mock_request)
|
||||
|
||||
# S3 URLs should be returned as-is (already absolute)
|
||||
self.assertTrue(result.startswith("http://s3.test:8080/test"))
|
||||
@@ -1,110 +0,0 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.validation import (
|
||||
MAX_FILE_NAME_LENGTH,
|
||||
MAX_PATH_COMPONENT_LENGTH,
|
||||
validate_file_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeFilePath(TestCase):
|
||||
"""Test validate_file_name function"""
|
||||
|
||||
def test_sanitize_valid_filename(self):
|
||||
"""Test sanitizing valid filename"""
|
||||
validate_file_name("test.png")
|
||||
|
||||
def test_sanitize_valid_path_with_directory(self):
|
||||
"""Test sanitizing valid path with directory"""
|
||||
validate_file_name("images/test.png")
|
||||
|
||||
def test_sanitize_valid_path_with_nested_dirs(self):
|
||||
"""Test sanitizing valid path with nested directories"""
|
||||
validate_file_name("dir1/dir2/dir3/test.png")
|
||||
|
||||
def test_sanitize_with_hyphens(self):
|
||||
"""Test sanitizing filename with hyphens"""
|
||||
validate_file_name("test-file-name.png")
|
||||
|
||||
def test_sanitize_with_underscores(self):
|
||||
"""Test sanitizing filename with underscores"""
|
||||
validate_file_name("test_file_name.png")
|
||||
|
||||
def test_sanitize_with_dots(self):
|
||||
"""Test sanitizing filename with multiple dots"""
|
||||
validate_file_name("test.file.name.png")
|
||||
|
||||
def test_sanitize_strips_whitespace(self):
|
||||
"""Test sanitizing filename strips whitespace"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(" test.png ")
|
||||
|
||||
def test_sanitize_removes_duplicate_slashes(self):
|
||||
"""Test sanitizing path removes duplicate slashes"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("dir1//dir2///test.png")
|
||||
|
||||
def test_sanitize_empty_path_raises(self):
|
||||
"""Test sanitizing empty path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("")
|
||||
|
||||
def test_sanitize_whitespace_only_raises(self):
|
||||
"""Test sanitizing whitespace-only path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(" ")
|
||||
|
||||
def test_sanitize_invalid_characters_raises(self):
|
||||
"""Test sanitizing path with invalid characters raises ValidationError"""
|
||||
invalid_paths = [
|
||||
"test file.png", # space
|
||||
"test@file.png", # @
|
||||
"test#file.png", # #
|
||||
"test$file.png", # $
|
||||
"test%file.png", # %
|
||||
"test&file.png", # &
|
||||
"test*file.png", # *
|
||||
"test(file).png", # parentheses
|
||||
"test[file].png", # brackets
|
||||
"test{file}.png", # braces
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
def test_sanitize_absolute_path_raises(self):
|
||||
"""Test sanitizing absolute path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("/absolute/path/test.png")
|
||||
|
||||
def test_sanitize_parent_directory_raises(self):
|
||||
"""Test sanitizing path with parent directory reference raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("../test.png")
|
||||
|
||||
def test_sanitize_nested_parent_directory_raises(self):
|
||||
"""Test sanitizing path with nested parent directory reference raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("dir1/../test.png")
|
||||
|
||||
def test_sanitize_starts_with_dot_raises(self):
|
||||
"""Test sanitizing path starting with dot raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(".hidden")
|
||||
|
||||
def test_sanitize_too_long_path_raises(self):
|
||||
"""Test sanitizing too long path raises ValidationError"""
|
||||
long_path = "a" * (MAX_FILE_NAME_LENGTH + 1) + ".png"
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(long_path)
|
||||
|
||||
def test_sanitize_too_long_component_raises(self):
|
||||
"""Test sanitizing path with too long component raises ValidationError"""
|
||||
long_component = "a" * (MAX_PATH_COMPONENT_LENGTH + 1)
|
||||
path = f"dir/{long_component}.png"
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
@@ -1,129 +0,0 @@
|
||||
import shutil
|
||||
import socket
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG, UNSET
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
S3_TEST_ENDPOINT = "http://localhost:8020"
|
||||
|
||||
|
||||
def s3_test_server_available() -> bool:
|
||||
"""Check if the S3 test server is reachable."""
|
||||
|
||||
parsed = urlparse(S3_TEST_ENDPOINT)
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class FileTestFileBackendMixin:
|
||||
def setUp(self):
|
||||
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
|
||||
self.original_media_backend_path = CONFIG.get("storage.media.file.path", UNSET)
|
||||
self.media_backend_path = mkdtemp()
|
||||
CONFIG.set("storage.media.backend", "file")
|
||||
CONFIG.set("storage.media.file.path", str(self.media_backend_path))
|
||||
|
||||
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
|
||||
self.original_reports_backend_path = CONFIG.get("storage.reports.file.path", UNSET)
|
||||
self.reports_backend_path = mkdtemp()
|
||||
CONFIG.set("storage.reports.backend", "file")
|
||||
CONFIG.set("storage.reports.file.path", str(self.reports_backend_path))
|
||||
|
||||
def tearDown(self):
|
||||
if self.original_media_backend is not UNSET:
|
||||
CONFIG.set("storage.media.backend", self.original_media_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.media.backend")
|
||||
if self.original_media_backend_path is not UNSET:
|
||||
CONFIG.set("storage.media.file.path", self.original_media_backend_path)
|
||||
else:
|
||||
CONFIG.delete("storage.media.file.path")
|
||||
shutil.rmtree(self.media_backend_path)
|
||||
|
||||
if self.original_reports_backend is not UNSET:
|
||||
CONFIG.set("storage.reports.backend", self.original_reports_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.backend")
|
||||
if self.original_reports_backend_path is not UNSET:
|
||||
CONFIG.set("storage.reports.file.path", self.original_reports_backend_path)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.file.path")
|
||||
shutil.rmtree(self.reports_backend_path)
|
||||
|
||||
|
||||
class FileTestS3BackendMixin:
|
||||
def setUp(self):
|
||||
s3_config_keys = {
|
||||
"endpoint",
|
||||
"access_key",
|
||||
"secret_key",
|
||||
"bucket_name",
|
||||
}
|
||||
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
|
||||
CONFIG.set("storage.media.backend", "s3")
|
||||
self.original_media_s3_settings = {}
|
||||
for key in s3_config_keys:
|
||||
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
|
||||
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
self.media_s3_backend = S3Backend(FileUsage.MEDIA)
|
||||
self.media_s3_backend.client.create_bucket(Bucket=self.media_s3_bucket_name, ACL="private")
|
||||
|
||||
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
|
||||
CONFIG.set("storage.reports.backend", "s3")
|
||||
self.original_reports_s3_settings = {}
|
||||
for key in s3_config_keys:
|
||||
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
|
||||
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
self.reports_s3_backend = S3Backend(FileUsage.REPORTS)
|
||||
self.reports_s3_backend.client.create_bucket(
|
||||
Bucket=self.reports_s3_bucket_name, ACL="private"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
def delete_objects_in_bucket(client, bucket_name):
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=bucket_name)
|
||||
for page in pages:
|
||||
if "Contents" not in page:
|
||||
continue
|
||||
for obj in page["Contents"]:
|
||||
client.delete_object(Bucket=bucket_name, Key=obj["Key"])
|
||||
|
||||
delete_objects_in_bucket(self.media_s3_backend.client, self.media_s3_bucket_name)
|
||||
self.media_s3_backend.client.delete_bucket(Bucket=self.media_s3_bucket_name)
|
||||
if self.original_media_backend is not UNSET:
|
||||
CONFIG.set("storage.media.backend", self.original_media_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.media.backend")
|
||||
for k, v in self.original_media_s3_settings.items():
|
||||
if v is not UNSET:
|
||||
CONFIG.set(f"storage.media.s3.{k}", v)
|
||||
else:
|
||||
CONFIG.delete(f"storage.media.s3.{k}")
|
||||
|
||||
delete_objects_in_bucket(self.reports_s3_backend.client, self.reports_s3_bucket_name)
|
||||
self.reports_s3_backend.client.delete_bucket(Bucket=self.reports_s3_bucket_name)
|
||||
if self.original_reports_backend is not UNSET:
|
||||
CONFIG.set("storage.reports.backend", self.original_reports_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.backend")
|
||||
for k, v in self.original_reports_s3_settings.items():
|
||||
if v is not UNSET:
|
||||
CONFIG.set(f"storage.reports.s3.{k}", v)
|
||||
else:
|
||||
CONFIG.delete(f"storage.reports.s3.{k}")
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.admin.files.api import FileUsedByView, FileView
|
||||
|
||||
api_urlpatterns = [
|
||||
path("admin/file/", FileView.as_view(), name="files"),
|
||||
path("admin/file/used_by/", FileUsedByView.as_view(), name="files-used-by"),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
from enum import StrEnum
|
||||
from itertools import chain
|
||||
|
||||
|
||||
class FileApiUsage(StrEnum):
|
||||
"""Usage types for file API"""
|
||||
|
||||
MEDIA = "media"
|
||||
|
||||
|
||||
class FileManagedUsage(StrEnum):
|
||||
"""Usage types for managed files"""
|
||||
|
||||
REPORTS = "reports"
|
||||
|
||||
|
||||
FileUsage = StrEnum("FileUsage", [(v.name, v.value) for v in chain(FileApiUsage, FileManagedUsage)])
|
||||
@@ -1,79 +0,0 @@
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
# File upload limits
|
||||
MAX_FILE_NAME_LENGTH = 1024
|
||||
MAX_PATH_COMPONENT_LENGTH = 255
|
||||
|
||||
|
||||
def validate_file_name(name: str) -> None:
|
||||
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
|
||||
FileUsage.MEDIA
|
||||
).supports_file(name):
|
||||
return
|
||||
validate_upload_file_name(name)
|
||||
|
||||
|
||||
def validate_upload_file_name(
|
||||
name: str,
|
||||
ValidationError: type[Exception] = ValidationError,
|
||||
) -> None:
|
||||
"""Sanitize file path.
|
||||
|
||||
Args:
|
||||
file_path: The file path to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized file path
|
||||
|
||||
Raises:
|
||||
ValidationError: If file path is invalid
|
||||
"""
|
||||
if not name:
|
||||
raise ValidationError(_("File name cannot be empty"))
|
||||
|
||||
# Same regex is used in the frontend as well
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
|
||||
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
|
||||
)
|
||||
)
|
||||
|
||||
if "//" in name:
|
||||
raise ValidationError(_("File name cannot contain duplicate /"))
|
||||
|
||||
# Convert to posix path
|
||||
path = PurePosixPath(name)
|
||||
|
||||
# Check for absolute paths
|
||||
# Needs the / at the start. If it doesn't have it, it might still be unsafe, so see L53+
|
||||
if path.is_absolute():
|
||||
raise ValidationError(_("Absolute paths are not allowed"))
|
||||
|
||||
# Check for parent directory references
|
||||
if ".." in path.parts:
|
||||
raise ValidationError(_("Parent directory references ('..') are not allowed"))
|
||||
|
||||
# Disallow paths starting with dot (hidden files at root level)
|
||||
if str(path).startswith("."):
|
||||
raise ValidationError(_("Paths cannot start with '.'"))
|
||||
|
||||
# Check path length limits
|
||||
normalized = str(path)
|
||||
if len(normalized) > MAX_FILE_NAME_LENGTH:
|
||||
raise ValidationError(_(f"File name too long (max {MAX_FILE_NAME_LENGTH} characters)"))
|
||||
|
||||
for part in path.parts:
|
||||
if len(part) > MAX_PATH_COMPONENT_LENGTH:
|
||||
raise ValidationError(
|
||||
_(f"Path component too long (max {MAX_PATH_COMPONENT_LENGTH} characters)")
|
||||
)
|
||||
@@ -27,21 +27,83 @@ except OSError:
|
||||
ipc_key = None
|
||||
|
||||
|
||||
def validate_auth(header: bytes, format="bearer") -> str | None:
|
||||
def validate_auth(header: bytes) -> str | None:
|
||||
"""Validate that the header is in a correct format,
|
||||
returns type and credentials"""
|
||||
auth_credentials = header.decode().strip()
|
||||
if auth_credentials == "" or " " not in auth_credentials:
|
||||
return None
|
||||
auth_type, _, auth_credentials = auth_credentials.partition(" ")
|
||||
if not compare_digest(auth_type.lower(), format):
|
||||
if auth_type.lower() != "bearer":
|
||||
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
||||
return None
|
||||
raise AuthenticationFailed("Unsupported authentication type")
|
||||
if auth_credentials == "": # nosec # noqa
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
return auth_credentials
|
||||
|
||||
|
||||
def bearer_auth(raw_header: bytes) -> User | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
user = auth_user_lookup(raw_header)
|
||||
if not user:
|
||||
return None
|
||||
if not user.is_active:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
return user
|
||||
|
||||
|
||||
def auth_user_lookup(raw_header: bytes) -> User | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
# first, check traditional tokens
|
||||
key_token = Token.filter_not_expired(
|
||||
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||
).first()
|
||||
if key_token:
|
||||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user
|
||||
# then try to auth via JWT
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
# we want to check the parsed version too
|
||||
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
CTX_AUTH_VIA.set("jwt")
|
||||
return jwt_token.user
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = token_secret_key(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("secret_key")
|
||||
return user
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = token_ipc(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("ipc")
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
|
||||
def token_secret_key(value: str) -> User | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
outpost = outposts.first()
|
||||
return outpost.user
|
||||
|
||||
|
||||
class IPCUser(AnonymousUser):
|
||||
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
|
||||
|
||||
@@ -70,8 +132,13 @@ class IPCUser(AnonymousUser):
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def all_roles(self):
|
||||
return []
|
||||
|
||||
def token_ipc(value: str) -> User | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
if not ipc_key or not compare_digest(value, ipc_key):
|
||||
return None
|
||||
return IPCUser()
|
||||
|
||||
|
||||
class TokenAuthentication(BaseAuthentication):
|
||||
@@ -81,79 +148,12 @@ class TokenAuthentication(BaseAuthentication):
|
||||
"""Token-based authentication using HTTP Bearer authentication"""
|
||||
auth = get_authorization_header(request)
|
||||
|
||||
user_ctx = self.bearer_auth(auth)
|
||||
user = bearer_auth(auth)
|
||||
# None is only returned when the header isn't set.
|
||||
if not user_ctx:
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return user_ctx
|
||||
|
||||
def bearer_auth(self, raw_header: bytes) -> tuple[User, Any] | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
user_ctx = self.auth_user_lookup(raw_header)
|
||||
if not user_ctx:
|
||||
return None
|
||||
user, ctx = user_ctx
|
||||
if not user.is_active:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
return user, ctx
|
||||
|
||||
def auth_user_lookup(self, raw_header: bytes) -> tuple[User, Any] | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
# first, check traditional tokens
|
||||
key_token = Token.filter_not_expired(
|
||||
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||
).first()
|
||||
if key_token:
|
||||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user, key_token
|
||||
# then try to auth via JWT
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
# we want to check the parsed version too
|
||||
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
CTX_AUTH_VIA.set("jwt")
|
||||
return jwt_token.user, jwt_token
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user_outpost = self.token_secret_key(auth_credentials)
|
||||
if user_outpost:
|
||||
CTX_AUTH_VIA.set("secret_key")
|
||||
return user_outpost
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = self.token_ipc(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("ipc")
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
def token_ipc(self, value: str) -> tuple[User, None] | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
if not ipc_key or not compare_digest(value, ipc_key):
|
||||
return None
|
||||
return IPCUser(), None
|
||||
|
||||
def token_secret_key(self, value: str) -> tuple[User, Outpost] | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
outpost = outposts.first()
|
||||
return outpost.user, outpost
|
||||
return (user, None) # pragma: no cover
|
||||
|
||||
|
||||
class TokenSchema(OpenApiAuthenticationExtension):
|
||||
|
||||
@@ -13,13 +13,6 @@ class Pagination(pagination.PageNumberPagination):
|
||||
page_query_param = "page"
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
page_size = super().get_page_size(request)
|
||||
if page_size is not None:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
previous_page_number = 0
|
||||
if self.page.has_previous():
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from authentik.api.authentication import IPCUser, TokenAuthentication
|
||||
from authentik.api.authentication import bearer_auth
|
||||
from authentik.blueprints.tests import reconcile_app
|
||||
from authentik.core.models import Token, TokenIntents, UserTypes
|
||||
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
@@ -25,24 +24,24 @@ class TestAPIAuth(TestCase):
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""Test invalid type"""
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"foo bar"))
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth(b"foo bar")
|
||||
|
||||
def test_invalid_empty(self):
|
||||
"""Test invalid type"""
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"Bearer "))
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b""))
|
||||
self.assertIsNone(bearer_auth(b"Bearer "))
|
||||
self.assertIsNone(bearer_auth(b""))
|
||||
|
||||
def test_invalid_no_token(self):
|
||||
"""Test invalid with no token"""
|
||||
auth = b64encode(b":abc").decode()
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(f"Basic :{auth}".encode()))
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
auth = b64encode(b":abc").decode()
|
||||
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
||||
|
||||
def test_bearer_valid(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
|
||||
user, tk = TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
|
||||
self.assertEqual(user, token.user)
|
||||
self.assertEqual(token, token)
|
||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||
|
||||
def test_bearer_valid_deactivated(self):
|
||||
"""Test valid token"""
|
||||
@@ -51,7 +50,7 @@ class TestAPIAuth(TestCase):
|
||||
user.save()
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
|
||||
bearer_auth(f"Bearer {token.key}".encode())
|
||||
|
||||
@reconcile_app("authentik_outposts")
|
||||
def test_managed_outpost_fail(self):
|
||||
@@ -60,21 +59,20 @@ class TestAPIAuth(TestCase):
|
||||
outpost.user.delete()
|
||||
outpost.delete()
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
|
||||
@reconcile_app("authentik_outposts")
|
||||
def test_managed_outpost_success(self):
|
||||
"""Test managed outpost"""
|
||||
user, outpost = TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
self.assertEqual(outpost, Outpost.objects.filter(managed=MANAGED_OUTPOST).first())
|
||||
|
||||
def test_jwt_valid(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
access = AccessToken.objects.create(
|
||||
refresh = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
@@ -82,16 +80,14 @@ class TestAPIAuth(TestCase):
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
user, token = TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
||||
self.assertEqual(user, access.user)
|
||||
self.assertEqual(token, access)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
|
||||
def test_jwt_missing_scope(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
access = AccessToken.objects.create(
|
||||
refresh = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
@@ -100,12 +96,4 @@ class TestAPIAuth(TestCase):
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
||||
|
||||
def test_ipc(self):
|
||||
"""Test IPC auth (mock key)"""
|
||||
key = generate_id()
|
||||
with patch("authentik.api.authentication.ipc_key", key):
|
||||
user, ctx = TokenAuthentication().bearer_auth(f"Bearer {key}".encode())
|
||||
self.assertEqual(user, IPCUser())
|
||||
self.assertEqual(ctx, None)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from inspect import getmembers
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
|
||||
class TestAPIViewAuthnAuthz(APITestCase): ...
|
||||
|
||||
|
||||
def api_viewset_action(viewset: GenericViewSet, member: Callable) -> Callable:
|
||||
"""Test API Viewset action"""
|
||||
|
||||
def tester(self: TestAPIViewAuthnAuthz):
|
||||
if "permission_classes" in member.kwargs:
|
||||
self.assertNotEqual(
|
||||
member.kwargs["permission_classes"], [], "permission_classes should not be empty"
|
||||
)
|
||||
if "authentication_classes" in member.kwargs:
|
||||
self.assertNotEqual(
|
||||
member.kwargs["authentication_classes"],
|
||||
[],
|
||||
"authentication_classes should not be empty",
|
||||
)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
def api_view(view: APIView) -> Callable:
|
||||
|
||||
def tester(self: TestAPIViewAuthnAuthz):
|
||||
self.assertNotEqual(view.permission_classes, [], "permission_classes should not be empty")
|
||||
self.assertNotEqual(
|
||||
view.authentication_classes,
|
||||
[],
|
||||
"authentication_classes should not be empty",
|
||||
)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
# Tell django to load all URLs
|
||||
reverse("authentik_core:root-redirect")
|
||||
for viewset in all_subclasses(GenericViewSet):
|
||||
for act_name, member in getmembers(viewset(), lambda x: isinstance(x, Callable)):
|
||||
if not hasattr(member, "kwargs") or not hasattr(member, "mapping"):
|
||||
continue
|
||||
setattr(
|
||||
TestAPIViewAuthnAuthz,
|
||||
f"test_viewset_{viewset.__name__}_action_{act_name}",
|
||||
api_viewset_action(viewset, member),
|
||||
)
|
||||
for view in all_subclasses(APIView):
|
||||
setattr(
|
||||
TestAPIViewAuthnAuthz,
|
||||
f"test_view_{view.__name__}",
|
||||
api_view(view),
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""core Configs API"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.dispatch import Signal
|
||||
from django.http import HttpRequest
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
@@ -18,8 +19,6 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.context_processors.base import get_context_processors
|
||||
from authentik.lib.config import CONFIG
|
||||
@@ -64,28 +63,31 @@ class ConfigView(APIView):
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@staticmethod
|
||||
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
|
||||
def get_capabilities(self) -> list[Capabilities]:
|
||||
"""Get all capabilities this server instance supports"""
|
||||
caps = []
|
||||
if get_file_manager(FileUsage.MEDIA).manageable:
|
||||
deb_test = settings.DEBUG or settings.TEST
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", "file") == "s3"
|
||||
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
|
||||
or deb_test
|
||||
):
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
caps.append(cap)
|
||||
if request.tenant.impersonation:
|
||||
if self.request.tenant.impersonation:
|
||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||
if settings.DEBUG: # pragma: no cover
|
||||
caps.append(Capabilities.CAN_DEBUG)
|
||||
if "authentik.enterprise" in settings.INSTALLED_APPS:
|
||||
caps.append(Capabilities.IS_ENTERPRISE)
|
||||
for _, result in capabilities.send(sender=ConfigView):
|
||||
for _, result in capabilities.send(sender=self):
|
||||
if result:
|
||||
caps.append(result)
|
||||
return caps
|
||||
|
||||
@staticmethod
|
||||
def get_config(request: HttpRequest) -> ConfigSerializer:
|
||||
def get_config(self) -> ConfigSerializer:
|
||||
"""Get Config"""
|
||||
return ConfigSerializer(
|
||||
{
|
||||
@@ -96,7 +98,7 @@ class ConfigView(APIView):
|
||||
"send_pii": CONFIG.get("error_reporting.send_pii"),
|
||||
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
|
||||
},
|
||||
"capabilities": ConfigView.get_capabilities(request),
|
||||
"capabilities": self.get_capabilities(),
|
||||
"cache_timeout": CONFIG.get_int("cache.timeout"),
|
||||
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
|
||||
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
|
||||
@@ -106,4 +108,4 @@ class ConfigView(APIView):
|
||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Retrieve public configuration options"""
|
||||
return Response(ConfigView.get_config(request).data)
|
||||
return Response(self.get_config().data)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Literal
|
||||
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
|
||||
def validate(serializer_type: type[Serializer], location: Literal["body", "query"] = "body"):
|
||||
"""Validate incoming data with the specified serializer. Raw data can either be taken
|
||||
from request body or query string, defaulting to body.
|
||||
|
||||
Validated data is added to the function this decorator is used on with a named parameter
|
||||
based on the location of the data.
|
||||
|
||||
Example:
|
||||
|
||||
@validate(MySerializer)
|
||||
@validate(MyQuerySerializer, location="query")
|
||||
def my_action(self, request, *, body: MySerializer, query: MyQuerySerializer):
|
||||
...
|
||||
|
||||
"""
|
||||
|
||||
def wrapper_outer(func: Callable):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: ViewSet, request: Request, *args, **kwargs) -> Response:
|
||||
data = {}
|
||||
if location == "body":
|
||||
data = request.data
|
||||
elif location == "query":
|
||||
data = request.query_params
|
||||
else:
|
||||
raise ValueError(f"Invalid data location '{location}'")
|
||||
instance = serializer_type(
|
||||
data=data,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
instance.is_valid(raise_exception=True)
|
||||
kwargs[location] = instance
|
||||
return func(self, request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrapper_outer
|
||||
@@ -1,12 +1,10 @@
|
||||
"""authentik Blueprints app"""
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from inspect import ismethod
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from dramatiq.broker import get_broker
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -46,21 +44,8 @@ class ManagedAppConfig(AppConfig):
|
||||
module_name = f"{self.name}.{rel_module}"
|
||||
import_module(module_name)
|
||||
self.logger.info("Imported related module", module=module_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
if settings.DEBUG:
|
||||
# This is a heuristic for determining whether the exception was caused
|
||||
# "directly" by the `import_module` call or whether the initial import
|
||||
# succeeded and a later import (within the existing module) failed.
|
||||
# 1. <the calling function>
|
||||
# 2. importlib.import_module
|
||||
# 3. importlib._bootstrap._gcd_import
|
||||
# 4. importlib._bootstrap._find_and_load
|
||||
# 5. importlib._bootstrap._find_and_load_unlocked
|
||||
STACK_LENGTH_HEURISTIC = 5
|
||||
|
||||
stack_length = len(traceback.extract_tb(exc.__traceback__))
|
||||
if stack_length > STACK_LENGTH_HEURISTIC:
|
||||
raise
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import_relative("checks")
|
||||
import_relative("tasks")
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from django.db.models import Model, fields
|
||||
from django.db.models.fields.related import OneToOneField
|
||||
from drf_jsonschema_serializer.convert import converter, field_to_converter
|
||||
from rest_framework.fields import Field, JSONField, UUIDField
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
@@ -33,8 +32,6 @@ class PrimaryKeyRelatedFieldConverter:
|
||||
def convert(self, field: PrimaryKeyRelatedField):
|
||||
model: Model = field.queryset.model
|
||||
pk_field = model._meta.pk
|
||||
if isinstance(pk_field, OneToOneField):
|
||||
pk_field = pk_field.related_fields[0][1]
|
||||
if isinstance(pk_field, fields.UUIDField):
|
||||
return {"type": "string", "format": "uuid"}
|
||||
return {"type": "integer"}
|
||||
@@ -121,10 +118,7 @@ class Command(BaseCommand):
|
||||
model_instance: Model = model()
|
||||
if not isinstance(model_instance, SerializerModel):
|
||||
continue
|
||||
try:
|
||||
serializer_class = model_instance.serializer
|
||||
except NotImplementedError as exc:
|
||||
raise NotImplementedError(model_instance) from exc
|
||||
serializer_class = model_instance.serializer
|
||||
serializer = serializer_class(
|
||||
context={
|
||||
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import Token, User
|
||||
from authentik.core.models import Application, Token, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
@@ -27,6 +29,24 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(token.key, self.uid)
|
||||
|
||||
def test_application(self):
|
||||
"""Test application"""
|
||||
app = Application.objects.filter(slug=f"{self.uid}-app").first()
|
||||
self.assertIsNotNone(app)
|
||||
self.assertEqual(app.meta_icon, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_source(self):
|
||||
"""Test source"""
|
||||
source = OAuthSource.objects.filter(slug=f"{self.uid}-source").first()
|
||||
self.assertIsNotNone(source)
|
||||
self.assertEqual(source.icon, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_flow(self):
|
||||
"""Test flow"""
|
||||
flow = Flow.objects.filter(slug=f"{self.uid}-flow").first()
|
||||
self.assertIsNotNone(flow)
|
||||
self.assertEqual(flow.background, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_user(self):
|
||||
"""Test user"""
|
||||
user: User = User.objects.filter(username=self.uid).first()
|
||||
|
||||
@@ -36,7 +36,10 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
||||
self.assertTrue(importer.apply())
|
||||
role = Role.objects.filter(name=uid).first()
|
||||
self.assertIsNotNone(role)
|
||||
self.assertEqual(get_perms(role), {"authentik_blueprints.view_blueprintinstance"})
|
||||
self.assertEqual(
|
||||
list(role.group.permissions.all().values_list("codename", flat=True)),
|
||||
["view_blueprintinstance"],
|
||||
)
|
||||
|
||||
def test_object_permission(self):
|
||||
"""Test permissions"""
|
||||
@@ -50,5 +53,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
|
||||
user = User.objects.filter(username=uid).first()
|
||||
role = Role.objects.filter(name=uid).first()
|
||||
self.assertIsNotNone(flow)
|
||||
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
|
||||
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})
|
||||
self.assertEqual(get_perms(user, flow), ["view_flow"])
|
||||
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
|
||||
|
||||
@@ -16,7 +16,8 @@ from django.db.models.query_utils import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django_channels_postgres.models import GroupChannel, Message
|
||||
from guardian.models import RoleObjectPermission, UserObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -41,15 +42,6 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentDeviceConnection,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceToken as EndpointDeviceToken,
|
||||
)
|
||||
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsage
|
||||
from authentik.enterprise.providers.google_workspace.models import (
|
||||
@@ -109,7 +101,6 @@ def excluded_models() -> list[type[Model]]:
|
||||
DjangoGroup,
|
||||
ContentType,
|
||||
Permission,
|
||||
RoleObjectPermission,
|
||||
UserObjectPermission,
|
||||
# Base classes
|
||||
Provider,
|
||||
@@ -121,7 +112,6 @@ def excluded_models() -> list[type[Model]]:
|
||||
OutpostServiceConnection,
|
||||
Policy,
|
||||
PolicyBindingModel,
|
||||
Connector,
|
||||
# Classes that have other dependencies
|
||||
Session,
|
||||
AuthenticatedSession,
|
||||
@@ -149,13 +139,6 @@ def excluded_models() -> list[type[Model]]:
|
||||
MicrosoftEntraProviderGroup,
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
EndpointDeviceToken,
|
||||
Device,
|
||||
DeviceConnection,
|
||||
DeviceAuthenticationToken,
|
||||
AppleNonce,
|
||||
AgentDeviceConnection,
|
||||
DeviceFactSnapshot,
|
||||
DeviceToken,
|
||||
StreamEvent,
|
||||
UserConsent,
|
||||
@@ -394,12 +377,10 @@ class Importer:
|
||||
"""Apply object-level permissions for an entry"""
|
||||
for perm in entry.get_permissions(self._import):
|
||||
if perm.user is not None:
|
||||
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
|
||||
perm.permission, instance
|
||||
)
|
||||
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
|
||||
if perm.role is not None:
|
||||
role = Role.objects.get(pk=perm.role)
|
||||
role.assign_perms(perm.permission, obj=instance)
|
||||
role.assign_permission(perm.permission, obj=instance)
|
||||
|
||||
def apply(self) -> bool:
|
||||
"""Apply (create/update) models yaml, in database transaction"""
|
||||
|
||||
@@ -163,4 +163,4 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
|
||||
def current(self, request: Request) -> Response:
|
||||
"""Get current brand"""
|
||||
brand: Brand = request._request.brand
|
||||
return Response(CurrentBrandSerializer(brand, context={"request": request}).data)
|
||||
return Response(CurrentBrandSerializer(brand).data)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 16:22
|
||||
|
||||
import authentik.admin.files.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0010_brand_client_certificates_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_default_flow_background",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/images/flow_background.jpg"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_favicon",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/icons/icon.png"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_logo",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/icons/icon_left_brand.svg"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,11 +8,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.fields import FileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -33,11 +31,11 @@ class Brand(SerializerModel):
|
||||
|
||||
branding_title = models.TextField(default="authentik")
|
||||
|
||||
branding_logo = FileField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
||||
branding_favicon = FileField(default="/static/dist/assets/icons/icon.png")
|
||||
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
||||
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
|
||||
branding_custom_css = models.TextField(default="", blank=True)
|
||||
branding_default_flow_background = FileField(
|
||||
default="/static/dist/assets/images/flow_background.jpg",
|
||||
branding_default_flow_background = models.TextField(
|
||||
default="/static/dist/assets/images/flow_background.jpg"
|
||||
)
|
||||
|
||||
flow_authentication = models.ForeignKey(
|
||||
@@ -86,19 +84,25 @@ class Brand(SerializerModel):
|
||||
attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def branding_logo_url(self) -> str:
|
||||
"""Get branding_logo URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
|
||||
"""Get branding_logo with the correct prefix"""
|
||||
if self.branding_logo.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
|
||||
return self.branding_logo
|
||||
|
||||
def branding_favicon_url(self) -> str:
|
||||
"""Get branding_favicon URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
|
||||
"""Get branding_favicon with the correct prefix"""
|
||||
if self.branding_favicon.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
|
||||
return self.branding_favicon
|
||||
|
||||
def branding_default_flow_background_url(self) -> str:
|
||||
"""Get branding_default_flow_background URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
|
||||
"""Get branding_default_flow_background with the correct prefix"""
|
||||
if self.branding_default_flow_background.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background
|
||||
return self.branding_default_flow_background
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.brands.api import BrandSerializer
|
||||
|
||||
return BrandSerializer
|
||||
|
||||
@@ -4,16 +4,16 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
@@ -23,13 +23,19 @@ from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.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
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
set_file,
|
||||
set_file_url,
|
||||
)
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import CACHE_PREFIX, PolicyResult
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -52,26 +58,14 @@ class ApplicationSerializer(ModelSerializer):
|
||||
source="backchannel_providers", required=False, read_only=True, many=True
|
||||
)
|
||||
|
||||
meta_icon_url = ReadOnlyField(source="get_meta_icon")
|
||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
user = None
|
||||
user_data = None
|
||||
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
|
||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
||||
# for multiple applications. UserSerializer accesses user.ak_groups which
|
||||
# would otherwise trigger a query for each application.
|
||||
if user is not None:
|
||||
if "_cached_user_data" not in self.context:
|
||||
# Prefetch groups to avoid N+1
|
||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
||||
user_data = self.context["_cached_user_data"]
|
||||
|
||||
return app.get_launch_url(user, user_data=user_data)
|
||||
return app.get_launch_url(user)
|
||||
|
||||
def validate_slug(self, slug: str) -> str:
|
||||
if slug in Application.reserved_slugs:
|
||||
@@ -101,13 +95,13 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"open_in_new_tab",
|
||||
"meta_launch_url",
|
||||
"meta_icon",
|
||||
"meta_icon_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"policy_engine_mode",
|
||||
"group",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"meta_icon": {"read_only": True},
|
||||
"backchannel_providers": {"required": False},
|
||||
}
|
||||
|
||||
@@ -164,23 +158,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
applications.append(application)
|
||||
return applications
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
Re-fetch with proper prefetching for serialization
|
||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
||||
during serialization when get_provider() is called
|
||||
"""
|
||||
if not applications:
|
||||
return self.get_queryset().none()
|
||||
pks = [app.pk for app in applications]
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(pk__in=pks)
|
||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
self, paginated_apps: Iterator[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in paginated_apps:
|
||||
@@ -283,8 +262,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@@ -303,10 +280,50 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@permission_required("authentik_core.change_application")
|
||||
@extend_schema(
|
||||
request={
|
||||
"multipart/form-data": FileUploadSerializer,
|
||||
},
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
parser_classes=(MultiPartParser,),
|
||||
)
|
||||
def set_icon(self, request: Request, slug: str):
|
||||
"""Set application icon"""
|
||||
app: Application = self.get_object()
|
||||
return set_file(request, app, "meta_icon")
|
||||
|
||||
@permission_required("authentik_core.change_application")
|
||||
@extend_schema(
|
||||
request=FilePathSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
)
|
||||
def set_icon_url(self, request: Request, slug: str):
|
||||
"""Set application icon (as URL)"""
|
||||
app: Application = self.get_object()
|
||||
return set_file_url(request, app, "meta_icon")
|
||||
|
||||
@@ -13,7 +13,6 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.users import ParamUserSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
||||
@@ -72,13 +71,13 @@ class AdminDeviceViewSet(ViewSet):
|
||||
"""Viewset for authenticator devices"""
|
||||
|
||||
serializer_class = DeviceSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = []
|
||||
|
||||
def get_devices(self, **kwargs):
|
||||
"""Get all devices in all child classes"""
|
||||
for model in device_classes():
|
||||
device_set = get_objects_for_user(
|
||||
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}"
|
||||
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
|
||||
).filter(**kwargs)
|
||||
yield from device_set
|
||||
|
||||
@@ -86,7 +85,8 @@ class AdminDeviceViewSet(ViewSet):
|
||||
parameters=[ParamUserSerializer],
|
||||
responses={200: DeviceSerializer(many=True)},
|
||||
)
|
||||
@validate(ParamUserSerializer, "query")
|
||||
def list(self, request: Request, query: ParamUserSerializer) -> Response:
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Get all devices for current user"""
|
||||
return Response(DeviceSerializer(self.get_devices(**query.validated_data), many=True).data)
|
||||
args = ParamUserSerializer(data=request.query_params)
|
||||
args.is_valid(raise_exception=True)
|
||||
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)
|
||||
|
||||
@@ -14,35 +14,20 @@ from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class PartialUserSerializer(ModelSerializer):
|
||||
"""Partial User Serializer, does not include child relations."""
|
||||
@@ -52,11 +37,20 @@ class PartialUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
|
||||
|
||||
class RelatedGroupSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children/parents for groups"""
|
||||
class GroupChildSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children for groups"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
|
||||
@@ -75,16 +69,15 @@ class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
||||
parents_obj = SerializerMethodField(allow_null=True)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
users_obj = SerializerMethodField(allow_null=True)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
roles_obj = ListSerializer(
|
||||
child=RoleSerializer(),
|
||||
read_only=True,
|
||||
source="roles",
|
||||
required=False,
|
||||
)
|
||||
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
@property
|
||||
@@ -101,30 +94,25 @@ class GroupSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_children", "false")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_parents(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_parents", "false")).lower() == "true"
|
||||
|
||||
@extend_schema_field(PartialUserSerializer(many=True))
|
||||
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
return None
|
||||
return PartialUserSerializer(instance.users, many=True).data
|
||||
|
||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
||||
@extend_schema_field(GroupChildSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
|
||||
if not self._should_include_children:
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.children, many=True).data
|
||||
return GroupChildSerializer(instance.children, many=True).data
|
||||
|
||||
@extend_schema_field(RelatedGroupSerializer(many=True))
|
||||
def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
|
||||
if not self._should_include_parents:
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.parents, many=True).data
|
||||
def validate_parent(self, parent: Group | None):
|
||||
"""Validate group parent (if set), ensuring the parent isn't itself"""
|
||||
if not self.instance or not parent:
|
||||
return parent
|
||||
if str(parent.group_uuid) == str(self.instance.group_uuid):
|
||||
raise ValidationError(_("Cannot set group as parent of itself."))
|
||||
return parent
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
@@ -160,8 +148,8 @@ class GroupSerializer(ModelSerializer):
|
||||
"num_pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parents",
|
||||
"parents_obj",
|
||||
"parent",
|
||||
"parent_name",
|
||||
"users",
|
||||
"users_obj",
|
||||
"attributes",
|
||||
@@ -178,10 +166,9 @@ class GroupSerializer(ModelSerializer):
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
"parents": {
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
# TODO: This field isn't unique on the database which is hard to backport
|
||||
# hence we just validate the uniqueness here
|
||||
"name": {"validators": [UniqueValidator(Group.objects.all())]},
|
||||
}
|
||||
|
||||
|
||||
@@ -240,11 +227,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
search_fields = ["name", "is_superuser"]
|
||||
filterset_class = GroupFilter
|
||||
ordering = ["name"]
|
||||
authentication_classes = [
|
||||
TokenAuthentication,
|
||||
SessionAuthentication,
|
||||
AgentAuth,
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
@@ -260,17 +242,10 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
base_qs = Group.objects.all().prefetch_related("roles")
|
||||
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
# time
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch(
|
||||
"users",
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
base_qs = base_qs.prefetch_related("users")
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
@@ -279,16 +254,12 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
if self.serializer_class(context={"request": self.request})._should_include_children:
|
||||
base_qs = base_qs.prefetch_related("children")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_parents:
|
||||
base_qs = base_qs.prefetch_related("parents")
|
||||
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -298,7 +269,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
@@ -317,16 +287,15 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
permission_classes=[],
|
||||
)
|
||||
@validate(UserAccountSerializer)
|
||||
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||
def add_user(self, request: Request, pk: str) -> Response:
|
||||
"""Add user to group"""
|
||||
group: Group = self.get_object()
|
||||
user: User = (
|
||||
get_objects_for_user(request.user, "authentik_core.view_user")
|
||||
.filter(
|
||||
pk=body.validated_data.get("pk"),
|
||||
pk=request.data.get("pk"),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -348,16 +317,15 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
permission_classes=[],
|
||||
)
|
||||
@validate(UserAccountSerializer)
|
||||
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||
def remove_user(self, request: Request, pk: str) -> Response:
|
||||
"""Remove user from group"""
|
||||
group: Group = self.get_object()
|
||||
user: User = (
|
||||
get_objects_for_user(request.user, "authentik_core.view_user")
|
||||
.filter(
|
||||
pk=body.validated_data.get("pk"),
|
||||
pk=request.data.get("pk"),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.response import Response
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
from authentik.lib.models import DeprecatedMixin
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
|
||||
@@ -25,7 +24,6 @@ class TypeCreateSerializer(PassiveSerializer):
|
||||
|
||||
icon_url = CharField(required=False)
|
||||
requires_enterprise = BooleanField(default=False)
|
||||
deprecated = BooleanField(default=False)
|
||||
|
||||
|
||||
class CreatableType:
|
||||
@@ -71,7 +69,6 @@ class TypesMixin:
|
||||
"requires_enterprise": isinstance(
|
||||
subclass._meta.app_config, EnterpriseConfig
|
||||
),
|
||||
"deprecated": isinstance(instance, DeprecatedMixin),
|
||||
}
|
||||
)
|
||||
except NotImplementedError:
|
||||
|
||||
@@ -21,7 +21,6 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -129,20 +128,23 @@ class PropertyMappingViewSet(
|
||||
],
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
@validate(PropertyMappingTestSerializer)
|
||||
def test(self, request: Request, pk: str, body: PropertyMappingTestSerializer) -> Response:
|
||||
def test(self, request: Request, pk: str) -> Response:
|
||||
"""Test Property Mapping"""
|
||||
_mapping: PropertyMapping = self.get_object()
|
||||
# Use `get_subclass` to get correct class and correct `.evaluate` implementation
|
||||
mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
|
||||
# FIXME: when we separate policy mappings between ones for sources
|
||||
# and ones for providers, we need to make the user field optional for the source mapping
|
||||
test_params = self.PropertyMappingTestSerializer(data=request.data)
|
||||
if not test_params.is_valid():
|
||||
return Response(test_params.errors, status=400)
|
||||
|
||||
format_result = str(request.GET.get("format_result", "false")).lower() == "true"
|
||||
|
||||
context: dict = body.validated_data.get("context", {})
|
||||
context: dict = test_params.validated_data.get("context", {})
|
||||
context.setdefault("user", None)
|
||||
|
||||
if user := body.validated_data.get("user"):
|
||||
if user := test_params.validated_data.get("user"):
|
||||
# User permission check, only allow mapping testing for users that are readable
|
||||
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
|
||||
pk=user.pk
|
||||
@@ -150,7 +152,7 @@ class PropertyMappingViewSet(
|
||||
if not users.exists():
|
||||
raise PermissionDenied()
|
||||
context["user"] = user
|
||||
if group := body.validated_data.get("group"):
|
||||
if group := test_params.validated_data.get("group"):
|
||||
# Group permission check, only allow mapping testing for groups that are readable
|
||||
groups = get_objects_for_user(request.user, "authentik_core.view_group").filter(
|
||||
pk=group.pk
|
||||
|
||||
@@ -2,22 +2,31 @@
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
|
||||
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
set_file,
|
||||
set_file_url,
|
||||
)
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -27,7 +36,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
|
||||
managed = ReadOnlyField()
|
||||
component = SerializerMethodField()
|
||||
icon_url = ReadOnlyField()
|
||||
icon = ReadOnlyField(source="icon_url")
|
||||
|
||||
def get_component(self, obj: Source) -> str:
|
||||
"""Get object component so that we know how to edit the object"""
|
||||
@@ -35,6 +44,11 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return ""
|
||||
return obj.component
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["icon"] = CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Source
|
||||
fields = [
|
||||
@@ -42,7 +56,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"promoted",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"user_property_mappings",
|
||||
@@ -56,7 +69,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"managed",
|
||||
"user_path_template",
|
||||
"icon",
|
||||
"icon_url",
|
||||
]
|
||||
|
||||
|
||||
@@ -79,6 +91,47 @@ class SourceViewSet(
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return Source.objects.select_subclasses()
|
||||
|
||||
@permission_required("authentik_core.change_source")
|
||||
@extend_schema(
|
||||
request={
|
||||
"multipart/form-data": FileUploadSerializer,
|
||||
},
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
parser_classes=(MultiPartParser,),
|
||||
)
|
||||
def set_icon(self, request: Request, slug: str):
|
||||
"""Set source icon"""
|
||||
source: Source = self.get_object()
|
||||
return set_file(request, source, "icon")
|
||||
|
||||
@permission_required("authentik_core.change_source")
|
||||
@extend_schema(
|
||||
request=FilePathSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
)
|
||||
def set_icon_url(self, request: Request, slug: str):
|
||||
"""Set source icon (as URL)"""
|
||||
source: Source = self.get_object()
|
||||
return set_file_url(request, source, "icon")
|
||||
|
||||
@extend_schema(responses={200: UserSettingSerializer(many=True)})
|
||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||
def user_settings(self, request: Request) -> Response:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@@ -11,7 +12,6 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -107,12 +107,6 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class TokenSetKeySerializer(PassiveSerializer):
|
||||
"""Set token's key"""
|
||||
|
||||
key = CharField()
|
||||
|
||||
|
||||
class TokenViewSerializer(PassiveSerializer):
|
||||
"""Show token's current key"""
|
||||
|
||||
@@ -144,15 +138,19 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
owner_field = "user"
|
||||
rbac_allow_create_without_perm = True
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
if not self.request.user.is_superuser:
|
||||
instance = serializer.save(
|
||||
user=self.request.user,
|
||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||
)
|
||||
self.request.user.assign_perms_to_managed_role(
|
||||
"authentik_core.view_token_key", instance
|
||||
)
|
||||
assign_perm("authentik_core.view_token_key", self.request.user, instance)
|
||||
return instance
|
||||
return super().perform_create(serializer)
|
||||
|
||||
@@ -172,7 +170,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@permission_required("authentik_core.set_token_key")
|
||||
@extend_schema(
|
||||
request=TokenSetKeySerializer(),
|
||||
request=inline_serializer(
|
||||
"TokenSetKey",
|
||||
{
|
||||
"key": CharField(),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
204: OpenApiResponse(description="Successfully changed key"),
|
||||
400: OpenApiResponse(description="Missing key"),
|
||||
@@ -180,12 +183,11 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
@validate(TokenSetKeySerializer)
|
||||
def set_key(self, request: Request, identifier: str, body: TokenSetKeySerializer) -> Response:
|
||||
def set_key(self, request: Request, identifier: str) -> Response:
|
||||
"""Set token key. Action is logged as event. `authentik_core.set_token_key` permission
|
||||
is required."""
|
||||
token: Token = self.get_object()
|
||||
key = body.validated_data.get("key")
|
||||
key = request.data.get("key")
|
||||
if not key:
|
||||
return Response(status=400)
|
||||
token.key = key
|
||||
|
||||
@@ -12,7 +12,6 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from yaml import ScalarNode
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.common import (
|
||||
Blueprint,
|
||||
BlueprintEntry,
|
||||
@@ -161,10 +160,11 @@ class TransactionalApplicationView(APIView):
|
||||
200: TransactionApplicationResponseSerializer(),
|
||||
},
|
||||
)
|
||||
@validate(TransactionApplicationSerializer)
|
||||
def put(self, request: Request, body: TransactionApplicationSerializer) -> Response:
|
||||
def put(self, request: Request) -> Response:
|
||||
"""Convert data into a blueprint, validate it and apply it"""
|
||||
blueprint: Blueprint = body.validated_data
|
||||
data = TransactionApplicationSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
blueprint: Blueprint = data.validated_data
|
||||
for entry in blueprint.entries:
|
||||
full_model = entry.get_model(blueprint)
|
||||
app, __, model = full_model.partition(".")
|
||||
|
||||
@@ -24,7 +24,6 @@ class DeleteAction(Enum):
|
||||
CASCADE_MANY = "cascade_many"
|
||||
SET_NULL = "set_null"
|
||||
SET_DEFAULT = "set_default"
|
||||
LEFT_DANGLING = "left_dangling"
|
||||
|
||||
|
||||
class UsedBySerializer(PassiveSerializer):
|
||||
@@ -81,7 +80,7 @@ class UsedByMixin:
|
||||
# query and check if there is a difference between modes the user can see
|
||||
# and can't see and add a warning
|
||||
for obj in get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", manager.all()
|
||||
request.user, f"{app}.view_{model_name}", manager
|
||||
).all():
|
||||
# Only merge shadows on first object
|
||||
if first_object:
|
||||
|
||||
@@ -31,7 +31,6 @@ from drf_spectacular.utils import (
|
||||
inline_serializer,
|
||||
)
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
@@ -43,7 +42,6 @@ from rest_framework.fields import (
|
||||
ListField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
@@ -54,8 +52,6 @@ from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -79,17 +75,14 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
from authentik.rbac.models import get_permission_choices
|
||||
from authentik.stages.email.flow import pickle_flow_token_for_email
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
@@ -108,6 +101,7 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
"""Partial Group Serializer, does not include child relations."""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
@@ -116,6 +110,8 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
"num_pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parent",
|
||||
"parent_name",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
@@ -134,13 +130,6 @@ class UserSerializer(ModelSerializer):
|
||||
default=list,
|
||||
)
|
||||
groups_obj = SerializerMethodField(allow_null=True)
|
||||
roles = PrimaryKeyRelatedField(
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
default=list,
|
||||
)
|
||||
roles_obj = SerializerMethodField(allow_null=True)
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(
|
||||
max_length=150,
|
||||
@@ -154,25 +143,12 @@ class UserSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_groups", "true")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_roles(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_roles", "true")).lower() == "true"
|
||||
|
||||
@extend_schema_field(PartialGroupSerializer(many=True))
|
||||
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
|
||||
if not self._should_include_groups:
|
||||
return None
|
||||
return PartialGroupSerializer(instance.ak_groups, many=True).data
|
||||
|
||||
@extend_schema_field(RoleSerializer(many=True))
|
||||
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
|
||||
if not self._should_include_roles:
|
||||
return None
|
||||
return RoleSerializer(instance.roles, many=True).data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
@@ -187,26 +163,24 @@ class UserSerializer(ModelSerializer):
|
||||
directly setting a password. However should be done via the `set_password`
|
||||
method instead of directly setting it like rest_framework."""
|
||||
password = validated_data.pop("password", None)
|
||||
perms_qs = Permission.objects.filter(
|
||||
permissions = Permission.objects.filter(
|
||||
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
|
||||
).values_list("content_type__app_label", "codename")
|
||||
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
|
||||
)
|
||||
validated_data["user_permissions"] = permissions
|
||||
instance: User = super().create(validated_data)
|
||||
self._set_password(instance, password)
|
||||
instance.assign_perms_to_managed_role(perms_list)
|
||||
return instance
|
||||
|
||||
def update(self, instance: User, validated_data: dict) -> User:
|
||||
"""Same as `create` above, set the password directly if we're in a blueprint
|
||||
context"""
|
||||
password = validated_data.pop("password", None)
|
||||
perms_qs = Permission.objects.filter(
|
||||
permissions = Permission.objects.filter(
|
||||
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
|
||||
).values_list("content_type__app_label", "codename")
|
||||
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
|
||||
)
|
||||
validated_data["user_permissions"] = permissions
|
||||
instance = super().update(instance, validated_data)
|
||||
self._set_password(instance, password)
|
||||
instance.assign_perms_to_managed_role(perms_list)
|
||||
return instance
|
||||
|
||||
def _set_password(self, instance: User, password: str | None):
|
||||
@@ -261,8 +235,6 @@ class UserSerializer(ModelSerializer):
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"groups_obj",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"email",
|
||||
"avatar",
|
||||
"attributes",
|
||||
@@ -286,7 +258,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
avatar = SerializerMethodField()
|
||||
groups = SerializerMethodField()
|
||||
roles = SerializerMethodField()
|
||||
uid = CharField(read_only=True)
|
||||
settings = SerializerMethodField()
|
||||
system_permissions = SerializerMethodField()
|
||||
@@ -314,25 +285,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"pk": group.pk,
|
||||
}
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
child=inline_serializer(
|
||||
"UserSelfRoles",
|
||||
{
|
||||
"name": CharField(read_only=True),
|
||||
"pk": CharField(read_only=True),
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
def get_roles(self, _: User):
|
||||
"""Return only the roles a user is member of"""
|
||||
for role in self.instance.all_roles().order_by("name"):
|
||||
yield {
|
||||
"name": role.name,
|
||||
"pk": role.pk,
|
||||
}
|
||||
|
||||
def get_settings(self, user: User) -> dict[str, Any]:
|
||||
"""Get user settings with brand and group settings applied"""
|
||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||
@@ -354,7 +306,6 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"roles",
|
||||
"email",
|
||||
"avatar",
|
||||
"uid",
|
||||
@@ -434,16 +385,6 @@ class UsersFilter(FilterSet):
|
||||
queryset=Group.objects.all().order_by("name"),
|
||||
)
|
||||
|
||||
roles_by_name = ModelMultipleChoiceFilter(
|
||||
field_name="roles__name",
|
||||
to_field_name="name",
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
)
|
||||
roles_by_pk = ModelMultipleChoiceFilter(
|
||||
field_name="roles",
|
||||
queryset=Role.objects.all().order_by("name"),
|
||||
)
|
||||
|
||||
def filter_is_superuser(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(ak_groups__is_superuser=True).distinct()
|
||||
@@ -479,17 +420,11 @@ class UsersFilter(FilterSet):
|
||||
"attributes",
|
||||
"groups_by_name",
|
||||
"groups_by_pk",
|
||||
"roles_by_name",
|
||||
"roles_by_pk",
|
||||
"type",
|
||||
]
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
@@ -497,11 +432,6 @@ class UserViewSet(
|
||||
serializer_class = UserSerializer
|
||||
filterset_class = UsersFilter
|
||||
search_fields = ["email", "name", "uuid", "username"]
|
||||
authentication_classes = [
|
||||
TokenAuthentication,
|
||||
SessionAuthentication,
|
||||
AgentAuth,
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
@@ -525,14 +455,11 @@ class UserViewSet(
|
||||
base_qs = User.objects.all().exclude_anonymous()
|
||||
if self.serializer_class(context={"request": self.request})._should_include_groups:
|
||||
base_qs = base_qs.prefetch_related("ak_groups")
|
||||
if self.serializer_class(context={"request": self.request})._should_include_roles:
|
||||
base_qs = base_qs.prefetch_related("roles")
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_groups", bool, default=True),
|
||||
OpenApiParameter("include_roles", bool, default=True),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -602,13 +529,14 @@ class UserViewSet(
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
@validate(UserServiceAccountSerializer)
|
||||
def service_account(self, request: Request, body: UserServiceAccountSerializer) -> Response:
|
||||
def service_account(self, request: Request) -> Response:
|
||||
"""Create a new user account that is marked as a service account"""
|
||||
expires = body.validated_data.get("expires", now() + timedelta(days=360))
|
||||
data = UserServiceAccountSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
expires = data.validated_data.get("expires", now() + timedelta(days=360))
|
||||
|
||||
username = body.validated_data["name"]
|
||||
expiring = body.validated_data["expiring"]
|
||||
username = data.validated_data["name"]
|
||||
expiring = data.validated_data["expiring"]
|
||||
with atomic():
|
||||
try:
|
||||
user: User = User.objects.create(
|
||||
@@ -626,7 +554,7 @@ class UserViewSet(
|
||||
"user_uid": user.uid,
|
||||
"user_pk": user.pk,
|
||||
}
|
||||
if body.validated_data["create_group"] and self.request.user.has_perm(
|
||||
if data.validated_data["create_group"] and self.request.user.has_perm(
|
||||
"authentik_core.add_group"
|
||||
):
|
||||
group = Group.objects.create(name=username)
|
||||
@@ -696,17 +624,14 @@ class UserViewSet(
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
@validate(UserPasswordSetSerializer)
|
||||
def set_password(self, request: Request, pk: int, body: UserPasswordSetSerializer) -> Response:
|
||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||
def set_password(self, request: Request, pk: int) -> Response:
|
||||
"""Set password for user"""
|
||||
data = UserPasswordSetSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
user: User = self.get_object()
|
||||
try:
|
||||
user.set_password(body.validated_data["password"], request=request)
|
||||
user.set_password(data.validated_data["password"], request=request)
|
||||
user.save()
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password", exc=exc)
|
||||
@@ -786,7 +711,7 @@ class UserViewSet(
|
||||
204: OpenApiResponse(description="Successfully started impersonation"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated])
|
||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||
def impersonate(self, request: Request, pk: int) -> Response:
|
||||
"""Impersonate a user"""
|
||||
if not request.tenant.impersonation:
|
||||
|
||||
@@ -12,27 +12,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class ModelBackendNoAuthz(ModelBackend):
|
||||
def get_user_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def get_group_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def get_all_permissions(self, user_obj, obj=None):
|
||||
return set()
|
||||
|
||||
def has_perm(self, user_obj, perm, obj=None):
|
||||
return False
|
||||
|
||||
def has_module_perms(self, user_obj, app_label):
|
||||
return False
|
||||
|
||||
def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
|
||||
return User.objects.none()
|
||||
|
||||
|
||||
class InbuiltBackend(ModelBackendNoAuthz):
|
||||
class InbuiltBackend(ModelBackend):
|
||||
"""Inbuilt backend"""
|
||||
|
||||
def authenticate(
|
||||
|
||||
@@ -6,6 +6,7 @@ import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import guardian.mixins
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -110,7 +111,7 @@ class Migration(migrations.Migration):
|
||||
options={
|
||||
"permissions": (("reset_user_password", "Reset Password"),),
|
||||
},
|
||||
bases=(models.Model,),
|
||||
bases=(guardian.mixins.GuardianUserMixin, models.Model),
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-23 14:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0051_group_authentik_c_is_supe_1e5a97_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="source",
|
||||
name="promoted",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, this source will be displayed as a prominent button on the login page, instead of a small icon.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-25 16:36
|
||||
|
||||
import django.core.validators
|
||||
import re
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0052_source_promoted"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="slug",
|
||||
field=models.TextField(
|
||||
help_text="Internal application name, used in URLs.",
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
re.compile("^[-a-zA-Z0-9_]+\\Z"),
|
||||
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
|
||||
"invalid",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="slug",
|
||||
field=models.TextField(
|
||||
help_text="Internal source name, used in URLs.",
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
re.compile("^[-a-zA-Z0-9_]+\\Z"),
|
||||
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
|
||||
"invalid",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 16:22
|
||||
|
||||
import authentik.admin.files.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_cache(apps, schema_editor):
|
||||
CacheEntry = apps.get_model("django_postgres_cache", "CacheEntry")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CacheEntry.objects.using(db_alias).all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0053_alter_application_slug_alter_source_slug"),
|
||||
("django_postgres_cache", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="meta_icon",
|
||||
field=authentik.admin.files.fields.FileField(blank=True, default=""),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="icon",
|
||||
field=authentik.admin.files.fields.FileField(blank=True, default=""),
|
||||
),
|
||||
migrations.RunPython(code=clear_cache),
|
||||
]
|
||||
@@ -1,155 +0,0 @@
|
||||
# Generated by Django 5.1.12 on 2025-09-12 08:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
import psqlextra.backend.migrations.operations.apply_state
|
||||
import psqlextra.backend.migrations.operations.create_materialized_view_model
|
||||
import psqlextra.indexes.unique_index
|
||||
import psqlextra.manager.manager
|
||||
import psqlextra.models.view
|
||||
import uuid
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_parents(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Group = apps.get_model("authentik_core", "Group")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for group in Group.objects.using(db_alias).all():
|
||||
if not group.parent:
|
||||
continue
|
||||
group.parents.add(group.parent)
|
||||
group.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0054_alter_application_meta_icon_alter_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GroupParentageNode",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Group Parentage Node",
|
||||
"verbose_name_plural": "Group Parentage Nodes",
|
||||
"db_table": "authentik_core_groupparentage",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupparentagenode",
|
||||
name="child",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="parent_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupparentagenode",
|
||||
name="parent",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="child_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
psqlextra.backend.migrations.operations.create_materialized_view_model.PostgresCreateMaterializedViewModel(
|
||||
name="GroupAncestryNode",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_groupancestry",
|
||||
},
|
||||
view_options={
|
||||
"query": (
|
||||
"\n WITH RECURSIVE accumulator AS (\n SELECT\n child_id::text || '-' || parent_id::text as id,\n child_id AS descendant_id,\n parent_id AS ancestor_id\n FROM authentik_core_groupparentage\n\n UNION\n\n SELECT\n accumulator.descendant_id::text || '-' || current.parent_id::text as id,\n accumulator.descendant_id,\n current.parent_id AS ancestor_id\n FROM accumulator\n JOIN authentik_core_groupparentage current\n ON accumulator.ancestor_id = current.child_id\n )\n SELECT * FROM accumulator\n ",
|
||||
(),
|
||||
),
|
||||
},
|
||||
bases=(psqlextra.models.view.PostgresMaterializedViewModel,),
|
||||
managers=[
|
||||
("objects", psqlextra.manager.manager.PostgresManager()),
|
||||
],
|
||||
),
|
||||
psqlextra.backend.migrations.operations.apply_state.ApplyState(
|
||||
state_operation=migrations.AddField(
|
||||
model_name="groupancestrynode",
|
||||
name="ancestor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="descendant_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
),
|
||||
psqlextra.backend.migrations.operations.apply_state.ApplyState(
|
||||
state_operation=migrations.AddField(
|
||||
model_name="groupancestrynode",
|
||||
name="descendant",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="ancestor_nodes",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=models.Index(fields=["descendant"], name="authentik_c_descend_f83a71_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=models.Index(fields=["ancestor"], name="authentik_c_ancesto_974845_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="groupancestrynode",
|
||||
index=psqlextra.indexes.unique_index.UniqueIndex(
|
||||
fields=["id"], name="authentik_c_id_5d0bb4_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="groupparentagenode",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="refresh_groupancestry",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func="\n REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;\n RETURN NULL;\n ",
|
||||
hash="a987621714359aa0389e03fd2d52f86b118e7d24",
|
||||
operation="INSERT OR UPDATE OR DELETE",
|
||||
pgid="pgtrigger_refresh_groupancestry_62450",
|
||||
table="authentik_core_groupparentage",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="group",
|
||||
name="parents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="children",
|
||||
through="authentik_core.GroupParentageNode",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_parents, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,178 +0,0 @@
|
||||
# Generated by Django 5.1.12 on 2025-09-30 12:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
Role = apps.get_model("authentik_rbac", "Role")
|
||||
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
|
||||
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
|
||||
RoleObjectPermission = apps.get_model("guardian", "RoleObjectPermission")
|
||||
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
|
||||
|
||||
def get_role_for_user_id(user_id: int) -> Role:
|
||||
name = f"ak-migrated-role--user-{user_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
name=name,
|
||||
)
|
||||
if created:
|
||||
role.users.add(user_id)
|
||||
return role
|
||||
|
||||
def get_role_for_group_id(group_id: int) -> Role:
|
||||
role = Role.objects.using(db_alias).filter(group_id=group_id).first()
|
||||
if not role:
|
||||
# Every django group should already have a role, so this should never happen.
|
||||
# But let's be nice.
|
||||
name = f"ak-migrated-role--group-{group_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
)
|
||||
if created:
|
||||
role.group_id = group_id
|
||||
role.save()
|
||||
return role
|
||||
|
||||
# Below are 4 very similar pieces of code, for (user, group) x (model, object).
|
||||
# Since this is a one-off migration, I won't attempt DRYing them.
|
||||
|
||||
# User model permissions
|
||||
user_ids_with_model_permissions = (
|
||||
User.user_permissions.through.objects.using(db_alias)
|
||||
.values_list("user", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for user_id in user_ids_with_model_permissions:
|
||||
role = get_role_for_user_id(user_id)
|
||||
user_model_permissions = User.user_permissions.through.objects.using(db_alias).filter(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
role_model_permissions = []
|
||||
for user_model_permission in user_model_permissions:
|
||||
role_model_permissions.append(
|
||||
RoleModelPermission(
|
||||
permission=user_model_permission.permission,
|
||||
content_type=user_model_permission.permission.content_type,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
|
||||
|
||||
# Group model permissions
|
||||
group_ids_with_model_permissions = (
|
||||
Group.permissions.through.objects.using(db_alias).values_list("group", flat=True).distinct()
|
||||
)
|
||||
for group_id in group_ids_with_model_permissions:
|
||||
role = get_role_for_group_id(group_id)
|
||||
group_model_permissions = Group.permissions.through.objects.using(db_alias).filter(
|
||||
group_id=group_id
|
||||
)
|
||||
|
||||
role_model_permissions = []
|
||||
for group_model_permission in group_model_permissions:
|
||||
role_model_permissions.append(
|
||||
RoleModelPermission(
|
||||
permission=group_model_permission.permission,
|
||||
content_type=group_model_permission.permission.content_type,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
|
||||
|
||||
# User object permissions
|
||||
user_ids_with_object_permissions = (
|
||||
UserObjectPermission.objects.using(db_alias).values_list("user", flat=True).distinct()
|
||||
)
|
||||
for user_id in user_ids_with_object_permissions:
|
||||
role = get_role_for_user_id(user_id)
|
||||
user_object_permissions = UserObjectPermission.objects.using(db_alias).filter(user=user_id)
|
||||
|
||||
role_object_permissions = []
|
||||
for user_object_permission in user_object_permissions:
|
||||
role_object_permissions.append(
|
||||
RoleObjectPermission(
|
||||
permission=user_object_permission.permission,
|
||||
content_type=user_object_permission.content_type,
|
||||
object_pk=user_object_permission.object_pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
|
||||
|
||||
# Group object permissions
|
||||
group_ids_with_object_permissions = (
|
||||
GroupObjectPermission.objects.using(db_alias).values_list("group", flat=True).distinct()
|
||||
)
|
||||
for group_id in group_ids_with_object_permissions:
|
||||
role = get_role_for_group_id(group_id)
|
||||
group_object_permissions = GroupObjectPermission.objects.using(db_alias).filter(
|
||||
group=group_id
|
||||
)
|
||||
|
||||
role_object_permissions = []
|
||||
for group_object_permission in group_object_permissions:
|
||||
role_object_permissions.append(
|
||||
RoleObjectPermission(
|
||||
permission=group_object_permission.permission,
|
||||
content_type=group_object_permission.content_type,
|
||||
object_pk=group_object_permission.object_pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("guardian", "0004_role_permissions"),
|
||||
("authentik_core", "0055_groupancestor_groupparentagenode_group_parents"),
|
||||
("authentik_rbac", "0008_alter_role_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="roles",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="users", to="authentik_rbac.role"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_object_permissions),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="group",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="group",
|
||||
name="parents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="children",
|
||||
through="authentik_core.GroupParentageNode",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="group",
|
||||
name="parent",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="group",
|
||||
name="name",
|
||||
field=models.TextField(unique=True, verbose_name="name"),
|
||||
),
|
||||
]
|
||||
@@ -6,13 +6,11 @@ from hashlib import sha256
|
||||
from typing import Any, Optional, Self
|
||||
from uuid import uuid4
|
||||
|
||||
import pgtrigger
|
||||
from deepmerge import always_merger
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.models import AbstractUser, Permission
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.core.validators import validate_slug
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet, options
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
@@ -20,21 +18,18 @@ from django.http import HttpRequest
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_cte import CTE, with_cte
|
||||
from guardian.conf import settings
|
||||
from guardian.models import RoleModelPermission, RoleObjectPermission
|
||||
from guardian.mixins import GuardianUserMixin
|
||||
from model_utils.managers import InheritanceManager
|
||||
from psqlextra.indexes import UniqueIndex
|
||||
from psqlextra.models import PostgresMaterializedViewModel
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.fields import FileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.blueprints.models import ManagedModel
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
@@ -45,7 +40,6 @@ from authentik.lib.models import (
|
||||
)
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
|
||||
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
|
||||
|
||||
@@ -72,17 +66,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
|
||||
|
||||
GROUP_RECURSION_LIMIT = 20
|
||||
|
||||
MANAGED_ROLE_PREFIX_USER = "ak-managed-role--user"
|
||||
MANAGED_ROLE_PREFIX_GROUP = "ak-managed-role--group"
|
||||
|
||||
|
||||
def managed_role_name(user_or_group: models.Model):
|
||||
if isinstance(user_or_group, User):
|
||||
return f"{MANAGED_ROLE_PREFIX_USER}-{user_or_group.pk}"
|
||||
if isinstance(user_or_group, Group):
|
||||
return f"{MANAGED_ROLE_PREFIX_GROUP}-{user_or_group.pk}"
|
||||
raise TypeError("Managed roles are only available for User or Group.")
|
||||
|
||||
|
||||
def default_token_duration() -> datetime:
|
||||
"""Default duration a Token is valid"""
|
||||
@@ -152,7 +135,7 @@ class AttributesMixin(models.Model):
|
||||
@classmethod
|
||||
def update_or_create_attributes(
|
||||
cls, query: dict[str, Any], properties: dict[str, Any]
|
||||
) -> tuple[Self, bool]:
|
||||
) -> tuple[models.Model, bool]:
|
||||
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
|
||||
instance = cls.objects.filter(**query).first()
|
||||
if not instance:
|
||||
@@ -162,40 +145,69 @@ class AttributesMixin(models.Model):
|
||||
|
||||
|
||||
class GroupQuerySet(QuerySet):
|
||||
def with_descendants(self):
|
||||
pks = self.values_list("pk", flat=True)
|
||||
return Group.objects.filter(Q(pk__in=pks) | Q(ancestor_nodes__ancestor__in=pks)).distinct()
|
||||
def with_children_recursive(self):
|
||||
"""Recursively get all groups that have the current queryset as parents
|
||||
or are indirectly related."""
|
||||
|
||||
def with_ancestors(self):
|
||||
pks = self.values_list("pk", flat=True)
|
||||
return Group.objects.filter(
|
||||
Q(pk__in=pks) | Q(descendant_nodes__descendant__in=pks)
|
||||
).distinct()
|
||||
def make_cte(cte):
|
||||
"""Build the query that ends up in WITH RECURSIVE"""
|
||||
# Start from self, aka the current query
|
||||
# Add a depth attribute to limit the recursion
|
||||
return self.annotate(
|
||||
relative_depth=models.Value(0, output_field=models.IntegerField())
|
||||
).union(
|
||||
# Here is the recursive part of the query. cte refers to the previous iteration
|
||||
# Only select groups for which the parent is part of the previous iteration
|
||||
# and increase the depth
|
||||
# Finally, limit the depth
|
||||
cte.join(Group, group_uuid=cte.col.parent_id)
|
||||
.annotate(
|
||||
relative_depth=models.ExpressionWrapper(
|
||||
cte.col.relative_depth
|
||||
+ models.Value(1, output_field=models.IntegerField()),
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
)
|
||||
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
|
||||
all=True,
|
||||
)
|
||||
|
||||
# Build the recursive query, see above
|
||||
cte = CTE.recursive(make_cte)
|
||||
# Return the result, as a usable queryset for Group.
|
||||
return with_cte(cte, select=cte.join(Group, group_uuid=cte.col.group_uuid))
|
||||
|
||||
|
||||
class Group(SerializerModel, AttributesMixin):
|
||||
"""Group model which supports a hierarchy and has attributes"""
|
||||
"""Group model which supports a basic hierarchy and has attributes"""
|
||||
|
||||
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField(verbose_name=_("name"), unique=True)
|
||||
name = models.TextField(_("name"))
|
||||
is_superuser = models.BooleanField(
|
||||
default=False, help_text=_("Users added to this group will be superusers.")
|
||||
)
|
||||
|
||||
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
|
||||
|
||||
parents = models.ManyToManyField(
|
||||
parent = models.ForeignKey(
|
||||
"Group",
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
through="GroupParentageNode",
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="children",
|
||||
)
|
||||
|
||||
objects = GroupQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
(
|
||||
"name",
|
||||
"parent",
|
||||
),
|
||||
)
|
||||
indexes = (
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["is_superuser"]),
|
||||
@@ -229,103 +241,12 @@ class Group(SerializerModel, AttributesMixin):
|
||||
"""Recursively check if `user` is member of us, or any parent."""
|
||||
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
|
||||
|
||||
def all_roles(self) -> QuerySet[Role]:
|
||||
"""Get all roles of this group and all of its ancestors."""
|
||||
return Role.objects.filter(
|
||||
ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
|
||||
).distinct()
|
||||
|
||||
def get_managed_role(self, create=False):
|
||||
if create:
|
||||
name = managed_role_name(self)
|
||||
role, created = Role.objects.get_or_create(name=name, managed=name)
|
||||
if created:
|
||||
role.ak_groups.add(self)
|
||||
return role
|
||||
else:
|
||||
return Role.objects.filter(name=managed_role_name(self)).first()
|
||||
|
||||
def assign_perms_to_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
if not perms:
|
||||
return
|
||||
role = self.get_managed_role(create=True)
|
||||
role.assign_perms(perms, obj)
|
||||
|
||||
|
||||
class GroupParentageNode(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
child = models.ForeignKey(Group, related_name="parent_nodes", on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey(Group, related_name="child_nodes", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Group Parentage Node")
|
||||
verbose_name_plural = _("Group Parentage Nodes")
|
||||
|
||||
db_table = "authentik_core_groupparentage"
|
||||
|
||||
triggers = [
|
||||
pgtrigger.Trigger(
|
||||
name="refresh_groupancestry",
|
||||
operation=pgtrigger.Insert | pgtrigger.Update | pgtrigger.Delete,
|
||||
when=pgtrigger.After,
|
||||
func="""
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;
|
||||
RETURN NULL;
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Group Parentage Node from #{self.child_id} to {self.parent_id}"
|
||||
|
||||
|
||||
class GroupAncestryNode(PostgresMaterializedViewModel):
|
||||
descendant = models.ForeignKey(
|
||||
Group, related_name="ancestor_nodes", on_delete=models.DO_NOTHING
|
||||
)
|
||||
ancestor = models.ForeignKey(
|
||||
Group, related_name="descendant_nodes", on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
class Meta:
|
||||
# This is a transitive closure of authentik_core_groupparentage
|
||||
# See https://en.wikipedia.org/wiki/Transitive_closure#In_graph_theory
|
||||
db_table = "authentik_core_groupancestry"
|
||||
indexes = [
|
||||
models.Index(fields=["descendant"]),
|
||||
models.Index(fields=["ancestor"]),
|
||||
UniqueIndex(fields=["id"]),
|
||||
]
|
||||
|
||||
class ViewMeta:
|
||||
query = """
|
||||
WITH RECURSIVE accumulator AS (
|
||||
SELECT
|
||||
child_id::text || '-' || parent_id::text as id,
|
||||
child_id AS descendant_id,
|
||||
parent_id AS ancestor_id
|
||||
FROM authentik_core_groupparentage
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
accumulator.descendant_id::text || '-' || current.parent_id::text as id,
|
||||
accumulator.descendant_id,
|
||||
current.parent_id AS ancestor_id
|
||||
FROM accumulator
|
||||
JOIN authentik_core_groupparentage current
|
||||
ON accumulator.ancestor_id = current.child_id
|
||||
)
|
||||
SELECT * FROM accumulator
|
||||
"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Group Ancestry Node from {self.descendant_id} to {self.ancestor_id}"
|
||||
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
|
||||
"""Compatibility layer for Group.objects.with_children_recursive()"""
|
||||
qs = self
|
||||
if not isinstance(self, QuerySet):
|
||||
qs = Group.objects.filter(group_uuid=self.group_uuid)
|
||||
return qs.with_children_recursive()
|
||||
|
||||
|
||||
class UserQuerySet(models.QuerySet):
|
||||
@@ -352,7 +273,7 @@ class UserManager(DjangoUserManager):
|
||||
return self.get_queryset().exclude_anonymous()
|
||||
|
||||
|
||||
class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
"""authentik User model, based on django's contrib auth user model."""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
||||
@@ -362,7 +283,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
|
||||
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True)
|
||||
password_change_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
@@ -400,60 +320,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
|
||||
def all_groups(self) -> QuerySet[Group]:
|
||||
"""Recursively get all groups this user is a member of."""
|
||||
return self.ak_groups.all().with_ancestors()
|
||||
|
||||
def all_roles(self) -> QuerySet[Role]:
|
||||
"""Get all roles of this user and all of its groups (recursively)."""
|
||||
return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct()
|
||||
|
||||
def get_managed_role(self, create=False):
|
||||
if create:
|
||||
name = managed_role_name(self)
|
||||
role, created = Role.objects.get_or_create(name=name, managed=name)
|
||||
if created:
|
||||
role.users.add(self)
|
||||
return role
|
||||
else:
|
||||
return Role.objects.filter(name=managed_role_name(self)).first()
|
||||
|
||||
def get_all_model_perms_on_managed_role(self) -> QuerySet[RoleModelPermission]:
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return RoleModelPermission.objects.none()
|
||||
return RoleModelPermission.objects.filter(role=role)
|
||||
|
||||
def get_all_obj_perms_on_managed_role(self) -> QuerySet[RoleObjectPermission]:
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return RoleObjectPermission.objects.none()
|
||||
return RoleObjectPermission.objects.filter(role=role)
|
||||
|
||||
def assign_perms_to_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
if not perms:
|
||||
return
|
||||
role = self.get_managed_role(create=True)
|
||||
role.assign_perms(perms, obj)
|
||||
|
||||
def remove_perms_from_managed_role(
|
||||
self,
|
||||
perms: str | list[str] | Permission | list[Permission],
|
||||
obj: models.Model | None = None,
|
||||
):
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return None
|
||||
role.remove_perms(perms, obj)
|
||||
|
||||
def remove_all_perms_from_managed_role(self):
|
||||
role = self.get_managed_role()
|
||||
if not role:
|
||||
return None
|
||||
RoleModelPermission.objects.filter(role=role).delete()
|
||||
RoleObjectPermission.objects.filter(role=role).delete()
|
||||
return self.ak_groups.all().with_children_recursive()
|
||||
|
||||
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
|
||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||
@@ -658,10 +525,6 @@ class ApplicationQuerySet(QuerySet):
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
# Also prefetch/select through each subclass path to ensure casted instances have access
|
||||
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
|
||||
qs = qs.select_related(f"provider__{subclass}__application")
|
||||
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
|
||||
return qs
|
||||
|
||||
|
||||
@@ -671,11 +534,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
add custom fields and other properties"""
|
||||
|
||||
name = models.TextField(help_text=_("Application's display Name."))
|
||||
slug = models.TextField(
|
||||
validators=[validate_slug],
|
||||
help_text=_("Internal application name, used in URLs."),
|
||||
unique=True,
|
||||
)
|
||||
slug = models.SlugField(help_text=_("Internal application name, used in URLs."), unique=True)
|
||||
group = models.TextField(blank=True, default="")
|
||||
|
||||
provider = models.OneToOneField(
|
||||
@@ -690,7 +549,13 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
default=False, help_text=_("Open launch URL in a new browser tab or window.")
|
||||
)
|
||||
|
||||
meta_icon = FileField(default="", blank=True)
|
||||
# For template applications, this can be set to /static/authentik/applications/*
|
||||
meta_icon = models.FileField(
|
||||
upload_to="application-icons/",
|
||||
default=None,
|
||||
null=True,
|
||||
max_length=500,
|
||||
)
|
||||
meta_description = models.TextField(default="", blank=True)
|
||||
meta_publisher = models.TextField(default="", blank=True)
|
||||
|
||||
@@ -707,21 +572,20 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
@property
|
||||
def get_meta_icon(self) -> str | None:
|
||||
"""Get the URL to the App Icon image"""
|
||||
"""Get the URL to the App Icon image. If the name is /static or starts with http
|
||||
it is returned as-is"""
|
||||
if not self.meta_icon:
|
||||
return None
|
||||
if self.meta_icon.name.startswith("http"):
|
||||
return self.meta_icon.name
|
||||
if self.meta_icon.name.startswith("fa://"):
|
||||
return self.meta_icon.name
|
||||
if self.meta_icon.name.startswith("/"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
|
||||
return self.meta_icon.url
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
|
||||
|
||||
def get_launch_url(
|
||||
self, user: Optional["User"] = None, user_data: dict | None = None
|
||||
) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
|
||||
|
||||
Args:
|
||||
user: User instance for formatting the URL
|
||||
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
|
||||
"""
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
@@ -731,10 +595,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
try:
|
||||
# Use pre-serialized data if available, otherwise serialize now
|
||||
if user_data is None:
|
||||
user_data = UserSerializer(instance=user).data
|
||||
return url % user_data
|
||||
return url % UserSerializer(instance=user).data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
@@ -859,30 +720,23 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
MANAGED_INBUILT = "goauthentik.io/sources/inbuilt"
|
||||
|
||||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
slug = models.TextField(
|
||||
validators=[validate_slug],
|
||||
help_text=_("Internal source name, used in URLs."),
|
||||
unique=True,
|
||||
)
|
||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||
|
||||
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
promoted = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"When enabled, this source will be displayed as a prominent button on the "
|
||||
"login page, instead of a small icon."
|
||||
),
|
||||
)
|
||||
user_property_mappings = models.ManyToManyField(
|
||||
"PropertyMapping", default=None, blank=True, related_name="source_userpropertymappings_set"
|
||||
)
|
||||
group_property_mappings = models.ManyToManyField(
|
||||
"PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set"
|
||||
)
|
||||
|
||||
icon = FileField(blank=True, default="")
|
||||
icon = models.FileField(
|
||||
upload_to="source-icons/",
|
||||
default=None,
|
||||
null=True,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
authentication_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
@@ -923,11 +777,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
"""Get the URL to the source icon"""
|
||||
"""Get the URL to the Icon. If the name is /static or
|
||||
starts with http it is returned as-is"""
|
||||
if not self.icon:
|
||||
return None
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
|
||||
if self.icon.name.startswith("http"):
|
||||
return self.icon.name
|
||||
if self.icon.name.startswith("fa://"):
|
||||
return self.icon.name
|
||||
if self.icon.name.startswith("/"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.icon.name
|
||||
return self.icon.url
|
||||
|
||||
def get_user_path(self) -> str:
|
||||
"""Get user path, fallback to default for formatting errors"""
|
||||
@@ -1069,7 +929,7 @@ class ExpiringModel(models.Model):
|
||||
return self.delete(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet["Self"]:
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
|
||||
|
||||
@@ -34,12 +34,19 @@ class SessionStore(SessionBase):
|
||||
|
||||
def _get_session_from_db(self):
|
||||
try:
|
||||
return self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
"authenticatedsession__user__user_permissions",
|
||||
)
|
||||
.get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
@@ -48,12 +55,19 @@ class SessionStore(SessionBase):
|
||||
|
||||
async def _aget_session_from_db(self):
|
||||
try:
|
||||
return await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
"authenticatedsession__user__user_permissions",
|
||||
)
|
||||
.aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
@@ -19,8 +17,6 @@ from authentik.core.models import (
|
||||
User,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
@@ -51,16 +47,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
if session:
|
||||
session.save()
|
||||
|
||||
if not RefreshOtherFlowsAfterAuthentication().get():
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
async_to_sync(layer.group_send)(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=AuthenticatedSession)
|
||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load authentik_core %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="{{ LANGUAGE_CODE }}"
|
||||
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
|
||||
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
|
||||
>
|
||||
@@ -17,7 +15,6 @@
|
||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ brand.branding_favicon_url }}">
|
||||
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
||||
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test Application Entitlements API"""
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application, ApplicationEntitlement, Group
|
||||
@@ -48,8 +49,7 @@ class TestApplicationEntitlements(APITestCase):
|
||||
def test_group_indirect(self):
|
||||
"""Test indirect group"""
|
||||
parent = Group.objects.create(name=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.parents.add(parent)
|
||||
group = Group.objects.create(name=generate_id(), parent=parent)
|
||||
self.user.ak_groups.add(group)
|
||||
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
|
||||
PolicyBinding.objects.create(target=ent, group=parent, order=0)
|
||||
@@ -76,8 +76,8 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_global(self):
|
||||
"""Test API creation with global permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_application")
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
assign_perm("authentik_core.view_application", self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
@@ -90,8 +90,8 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_scoped(self):
|
||||
"""Test API creation with scoped permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_application", self.app)
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
assign_perm("authentik_core.view_application", self.user, self.app)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
@@ -104,7 +104,7 @@ class TestApplicationEntitlements(APITestCase):
|
||||
|
||||
def test_api_perms_missing(self):
|
||||
"""Test API creation with no permissions"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
|
||||
assign_perm("authentik_core.add_applicationentitlement", self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:applicationentitlement-list"),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -55,6 +57,91 @@ class TestApplicationsAPI(APITestCase):
|
||||
f"https://{self.user.username}-test.test.goauthentik.io/{self.user.username}",
|
||||
)
|
||||
|
||||
def test_set_icon(self):
|
||||
"""Test set_icon"""
|
||||
file = ContentFile(b"text", "name")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data=encode_multipart(data={"file": file}, boundary=BOUNDARY),
|
||||
content_type=MULTIPART_CONTENT,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
app_raw = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:application-detail",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
)
|
||||
app = loads(app_raw.content)
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
|
||||
self.assertEqual(self.allowed.meta_icon.read(), b"text")
|
||||
|
||||
def test_set_icon_relative(self):
|
||||
"""Test set_icon (relative path)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "relative/path"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "/media/public/relative/path")
|
||||
|
||||
def test_set_icon_absolute(self):
|
||||
"""Test set_icon (absolute path)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "/relative/path"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "/relative/path")
|
||||
|
||||
def test_set_icon_url(self):
|
||||
"""Test set_icon (url)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "https://authentik.company/img.png"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "https://authentik.company/img.png")
|
||||
|
||||
def test_set_icon_fa(self):
|
||||
"""Test set_icon (url)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "fa://fa-check-circle"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "fa://fa-check-circle")
|
||||
|
||||
def test_check_access(self):
|
||||
"""Test check_access operation"""
|
||||
self.client.force_login(self.user)
|
||||
@@ -123,8 +210,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -178,8 +264,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -187,8 +272,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
{
|
||||
"launch_url": None,
|
||||
"meta_description": "",
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon": None,
|
||||
"meta_launch_url": "",
|
||||
"open_in_new_tab": False,
|
||||
"meta_publisher": "",
|
||||
|
||||
@@ -8,8 +8,6 @@ from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
|
||||
class TestApplicationsViews(FlowTestCase):
|
||||
@@ -17,7 +15,7 @@ class TestApplicationsViews(FlowTestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.app = Application.objects.create(
|
||||
self.allowed = Application.objects.create(
|
||||
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||
)
|
||||
|
||||
@@ -30,7 +28,7 @@ class TestApplicationsViews(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
kwargs={"application_slug": self.allowed.slug},
|
||||
),
|
||||
follow=True,
|
||||
)
|
||||
@@ -54,63 +52,8 @@ class TestApplicationsViews(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
kwargs={"application_slug": self.allowed.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
|
||||
|
||||
def test_redirect_application_auth_flow(self):
|
||||
"""Test launching an application with a provider and an authentication flow set"""
|
||||
self.client.logout()
|
||||
auth_flow = create_test_flow()
|
||||
prov = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authentication_flow=auth_flow,
|
||||
)
|
||||
self.app.provider = prov
|
||||
self.app.save()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": auth_flow.slug}),
|
||||
)
|
||||
plan = plan()
|
||||
self.assertEqual(len(plan.bindings), 1)
|
||||
self.assertTrue(plan.bindings[0].stage.is_in_memory)
|
||||
|
||||
def test_redirect_application_no_auth(self):
|
||||
"""Test launching an application with a provider and an authentication flow set"""
|
||||
self.client.logout()
|
||||
empty_flow = create_test_flow()
|
||||
brand: Brand = create_test_brand()
|
||||
brand.flow_authentication = empty_flow
|
||||
brand.save()
|
||||
|
||||
prov = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
self.app.provider = prov
|
||||
self.app.save()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": empty_flow.slug}),
|
||||
)
|
||||
plan = plan()
|
||||
self.assertEqual(len(plan.bindings), 1)
|
||||
self.assertTrue(plan.bindings[0].stage.is_in_memory)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user