Compare commits
10 Commits
celery-2-d
...
workspace-
Author | SHA1 | Date | |
---|---|---|---|
ab315504a4 | |||
89a24dc508 | |||
1fe72ee377 | |||
9f596079d9 | |||
6c444dffc6 | |||
3bcbb2c0f9 | |||
4e284818cf | |||
ced3f16310 | |||
b65aabafdc | |||
b07439dbe7 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2025.6.3
|
current_version = 2025.4.1
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||||
@ -21,8 +21,6 @@ optional_value = final
|
|||||||
|
|
||||||
[bumpversion:file:package.json]
|
[bumpversion:file:package.json]
|
||||||
|
|
||||||
[bumpversion:file:package-lock.json]
|
|
||||||
|
|
||||||
[bumpversion:file:docker-compose.yml]
|
[bumpversion:file:docker-compose.yml]
|
||||||
|
|
||||||
[bumpversion:file:schema.yml]
|
[bumpversion:file:schema.yml]
|
||||||
@ -33,4 +31,6 @@ optional_value = final
|
|||||||
|
|
||||||
[bumpversion:file:internal/constants/constants.go]
|
[bumpversion:file:internal/constants/constants.go]
|
||||||
|
|
||||||
|
[bumpversion:file:web/src/common/constants.ts]
|
||||||
|
|
||||||
[bumpversion:file:lifecycle/aws/template.yaml]
|
[bumpversion:file:lifecycle/aws/template.yaml]
|
||||||
|
@ -5,10 +5,8 @@ dist/**
|
|||||||
build/**
|
build/**
|
||||||
build_docs/**
|
build_docs/**
|
||||||
*Dockerfile
|
*Dockerfile
|
||||||
**/*Dockerfile
|
|
||||||
blueprints/local
|
blueprints/local
|
||||||
.git
|
.git
|
||||||
!gen-ts-api/node_modules
|
!gen-ts-api/node_modules
|
||||||
!gen-ts-api/dist/**
|
!gen-ts-api/dist/**
|
||||||
!gen-go-api/
|
!gen-go-api/
|
||||||
.venv
|
|
||||||
|
@ -7,9 +7,6 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.toml]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.html]
|
[*.html]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
|
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@ -28,15 +28,15 @@ runs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- name: Setup go
|
- name: Setup go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup docker cache
|
- name: Setup docker cache
|
||||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
uses: ScribeMD/docker-cache@0.5.0
|
||||||
with:
|
with:
|
||||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
@ -44,7 +44,7 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||||
cd web && npm ci
|
npm ci
|
||||||
- name: Generate config
|
- name: Generate config
|
||||||
shell: uv run python {0}
|
shell: uv run python {0}
|
||||||
run: |
|
run: |
|
||||||
|
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@ -100,13 +100,6 @@ updates:
|
|||||||
goauthentik:
|
goauthentik:
|
||||||
patterns:
|
patterns:
|
||||||
- "@goauthentik/*"
|
- "@goauthentik/*"
|
||||||
eslint:
|
|
||||||
patterns:
|
|
||||||
- "@eslint/*"
|
|
||||||
- "@typescript-eslint/*"
|
|
||||||
- "eslint-*"
|
|
||||||
- "eslint"
|
|
||||||
- "typescript-eslint"
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/lifecycle/aws"
|
directory: "/lifecycle/aws"
|
||||||
schedule:
|
schedule:
|
||||||
|
@ -38,8 +38,6 @@ jobs:
|
|||||||
# Needed for attestation
|
# Needed for attestation
|
||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
# Needed for checkout
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-qemu-action@v3.6.0
|
- uses: docker/setup-qemu-action@v3.6.0
|
||||||
|
15
.github/workflows/api-ts-publish.yml
vendored
15
.github/workflows/api-ts-publish.yml
vendored
@ -20,8 +20,11 @@ jobs:
|
|||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
- name: Prepare Dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
- name: Generate API Client
|
- name: Generate API Client
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
- name: Publish package
|
- name: Publish package
|
||||||
@ -32,15 +35,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||||
- name: Upgrade /web
|
- name: Upgrade /web
|
||||||
working-directory: web
|
|
||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("./gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION -w @goauthentik/web
|
||||||
- name: Upgrade /web/packages/sfe
|
- name: Upgrade /web/packages/sfe
|
||||||
working-directory: web/packages/sfe
|
|
||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("./gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION -w @goauthentik/web-sfe
|
||||||
- uses: peter-evans/create-pull-request@v7
|
- uses: peter-evans/create-pull-request@v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
|
3
.github/workflows/ci-main-daily.yml
vendored
3
.github/workflows/ci-main-daily.yml
vendored
@ -9,15 +9,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-container:
|
test-container:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
version:
|
version:
|
||||||
- docs
|
- docs
|
||||||
- version-2025-4
|
|
||||||
- version-2025-2
|
- version-2025-2
|
||||||
|
- version-2024-12
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: |
|
- run: |
|
||||||
|
21
.github/workflows/ci-main.yml
vendored
21
.github/workflows/ci-main.yml
vendored
@ -62,7 +62,6 @@ jobs:
|
|||||||
psql:
|
psql:
|
||||||
- 15-alpine
|
- 15-alpine
|
||||||
- 16-alpine
|
- 16-alpine
|
||||||
- 17-alpine
|
|
||||||
run_id: [1, 2, 3, 4, 5]
|
run_id: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -117,7 +116,6 @@ jobs:
|
|||||||
psql:
|
psql:
|
||||||
- 15-alpine
|
- 15-alpine
|
||||||
- 16-alpine
|
- 16-alpine
|
||||||
- 17-alpine
|
|
||||||
run_id: [1, 2, 3, 4, 5]
|
run_id: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -195,23 +193,22 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Setup e2e env (chrome, etc)
|
- name: Setup E2E environment (Chrome, etc)
|
||||||
run: |
|
run: |
|
||||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||||
- id: cache-web
|
- id: cache-web
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/dist
|
path: web/dist
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
key: ${{ runner.os }}-web-${{ hashFiles('package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||||
- name: prepare web ui
|
- name: Prepare Web UI
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
working-directory: web
|
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
make -C .. gen-client-ts
|
make gen-client-ts
|
||||||
npm run build
|
npm run build -w @goauthentik/web
|
||||||
npm run build:sfe
|
npm run build -w @goauthentik/web-sfe
|
||||||
- name: run e2e
|
- name: Run E2E
|
||||||
run: |
|
run: |
|
||||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||||
uv run coverage xml
|
uv run coverage xml
|
||||||
@ -247,13 +244,11 @@ jobs:
|
|||||||
# Needed for attestation
|
# Needed for attestation
|
||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
# Needed for checkout
|
|
||||||
contents: read
|
|
||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
uses: ./.github/workflows/_reusable-docker-build.yaml
|
uses: ./.github/workflows/_reusable-docker-build.yaml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
|
image_name: ghcr.io/goauthentik/dev-server
|
||||||
release: false
|
release: false
|
||||||
pr-comment:
|
pr-comment:
|
||||||
needs:
|
needs:
|
||||||
|
20
.github/workflows/ci-outpost.yml
vendored
20
.github/workflows/ci-outpost.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
|||||||
mkdir -p web/dist
|
mkdir -p web/dist
|
||||||
mkdir -p website/help
|
mkdir -p website/help
|
||||||
touch web/dist/test website/help/test
|
touch web/dist/test website/help/test
|
||||||
- name: Generate API
|
- name: Generate Golang API Client
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v8
|
||||||
@ -43,7 +43,7 @@ jobs:
|
|||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Generate API
|
- name: Generate Golang API Client
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Go unittests
|
- name: Go unittests
|
||||||
run: |
|
run: |
|
||||||
@ -59,7 +59,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
jobs: ${{ toJSON(needs) }}
|
jobs: ${{ toJSON(needs) }}
|
||||||
build-container:
|
build-container:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
needs:
|
needs:
|
||||||
- ci-outpost-mark
|
- ci-outpost-mark
|
||||||
@ -100,7 +99,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Generate API
|
- name: Generate Golang API Client
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
id: push
|
id: push
|
||||||
@ -146,16 +145,17 @@ jobs:
|
|||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- name: Generate API
|
- name: Generate Golang API Client
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Build web
|
- name: Prepare Dependencies
|
||||||
working-directory: web/
|
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build-proxy
|
- name: Run ESBuild
|
||||||
|
run: |
|
||||||
|
npm run build-proxy -w @goauthentik/web
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
|
43
.github/workflows/ci-web.yml
vendored
43
.github/workflows/ci-web.yml
vendored
@ -19,47 +19,45 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
command:
|
command:
|
||||||
- lint
|
- lint
|
||||||
- lint:lockfile
|
|
||||||
- tsc
|
|
||||||
- prettier-check
|
- prettier-check
|
||||||
project:
|
project:
|
||||||
- web
|
- web
|
||||||
include:
|
include:
|
||||||
- command: tsc
|
|
||||||
project: web
|
|
||||||
- command: lit-analyse
|
- command: lit-analyse
|
||||||
project: web
|
project: web
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: ${{ matrix.project }}/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- working-directory: ${{ matrix.project }}/
|
- name: Prepare Dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
- name: Generate API
|
- name: Generate TypeScript API
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
- name: Lint
|
- name: Lint Project
|
||||||
working-directory: ${{ matrix.project }}/
|
run: |
|
||||||
run: npm run ${{ matrix.command }}
|
npm run build-locales -w @goauthentik/web
|
||||||
|
npm run lint:types
|
||||||
|
- name: Lint Web
|
||||||
|
run: npm run ${{ matrix.command }} -w @goauthentik/web
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- working-directory: web/
|
- name: Prepare Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Generate API
|
- name: Generate TypeScript API
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
- name: build
|
- name: build
|
||||||
working-directory: web/
|
run: npm run build -w @goauthentik/web
|
||||||
run: npm run build
|
|
||||||
ci-web-mark:
|
ci-web-mark:
|
||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
@ -78,13 +76,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- working-directory: web/
|
- name: Prepare Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Generate API
|
- name: Generate TypeScript API
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
- name: test
|
- name: test
|
||||||
working-directory: web/
|
run: npm run test -w @goauthentik/web || exit 0
|
||||||
run: npm run test || exit 0
|
|
||||||
|
95
.github/workflows/ci-website.yml
vendored
95
.github/workflows/ci-website.yml
vendored
@ -14,113 +14,52 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
command:
|
|
||||||
- lint:lockfile
|
|
||||||
- prettier-check
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- working-directory: website/
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: package.json
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
- name: Prepare Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: website/
|
run: npm run prettier-check -w @goauthentik/docs
|
||||||
run: npm run ${{ matrix.command }}
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- working-directory: website/
|
- name: Prepare Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: test
|
- name: test
|
||||||
working-directory: website/
|
run: npm test -w @goauthentik/docs
|
||||||
run: npm test
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: ${{ matrix.job }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
job:
|
|
||||||
- build
|
|
||||||
- build:integrations
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: website/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- working-directory: website/
|
- name: Prepare Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: build
|
- name: Run Docusaurus
|
||||||
working-directory: website/
|
run: npm run build -w @goauthentik/docs
|
||||||
run: npm run ${{ matrix.job }}
|
|
||||||
build-container:
|
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
# Needed to upload container images to ghcr.io
|
|
||||||
packages: write
|
|
||||||
# Needed for attestation
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.6.0
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: prepare variables
|
|
||||||
uses: ./.github/actions/docker-push-variables
|
|
||||||
id: ev
|
|
||||||
env:
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
with:
|
|
||||||
image-name: ghcr.io/goauthentik/dev-docs
|
|
||||||
- name: Login to Container Registry
|
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build Docker Image
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
|
||||||
file: website/Dockerfile
|
|
||||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
context: .
|
|
||||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
|
||||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
|
||||||
- uses: actions/attest-build-provenance@v2
|
|
||||||
id: attest
|
|
||||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
|
||||||
with:
|
|
||||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
ci-website-mark:
|
ci-website-mark:
|
||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
- lint
|
- lint
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
- build-container
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: re-actors/alls-green@release/v1
|
- uses: re-actors/alls-green@release/v1
|
||||||
with:
|
with:
|
||||||
jobs: ${{ toJSON(needs) }}
|
jobs: ${{ toJSON(needs) }}
|
||||||
allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }}
|
|
||||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -2,7 +2,7 @@ name: "CodeQL"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, next, version*]
|
branches: [main, "*", next, version*]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
schedule:
|
schedule:
|
||||||
|
4
.github/workflows/packages-npm-publish.yml
vendored
4
.github/workflows/packages-npm-publish.yml
vendored
@ -7,7 +7,7 @@ on:
|
|||||||
- packages/eslint-config/**
|
- packages/eslint-config/**
|
||||||
- packages/prettier-config/**
|
- packages/prettier-config/**
|
||||||
- packages/tsconfig/**
|
- packages/tsconfig/**
|
||||||
- web/packages/esbuild-plugin-live-reload/**
|
- packages/web/esbuild-plugin-live-reload/**
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
@ -21,7 +21,7 @@ jobs:
|
|||||||
- packages/eslint-config
|
- packages/eslint-config
|
||||||
- packages/prettier-config
|
- packages/prettier-config
|
||||||
- packages/tsconfig
|
- packages/tsconfig
|
||||||
- web/packages/esbuild-plugin-live-reload
|
- packages/web/esbuild-plugin-live-reload
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
57
.github/workflows/release-publish.yml
vendored
57
.github/workflows/release-publish.yml
vendored
@ -20,49 +20,6 @@ jobs:
|
|||||||
release: true
|
release: true
|
||||||
registry_dockerhub: true
|
registry_dockerhub: true
|
||||||
registry_ghcr: true
|
registry_ghcr: true
|
||||||
build-docs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
# Needed to upload container images to ghcr.io
|
|
||||||
packages: write
|
|
||||||
# Needed for attestation
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.6.0
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: prepare variables
|
|
||||||
uses: ./.github/actions/docker-push-variables
|
|
||||||
id: ev
|
|
||||||
env:
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
with:
|
|
||||||
image-name: ghcr.io/goauthentik/docs
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build Docker Image
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
tags: ${{ steps.ev.outputs.imageTags }}
|
|
||||||
file: website/Dockerfile
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
context: .
|
|
||||||
- uses: actions/attest-build-provenance@v2
|
|
||||||
id: attest
|
|
||||||
if: true
|
|
||||||
with:
|
|
||||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
build-outpost:
|
build-outpost:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@ -149,14 +106,14 @@ jobs:
|
|||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: web/package.json
|
node-version-file: package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
- name: Build web
|
- name: Prepare Dependencies
|
||||||
working-directory: web/
|
run: npm ci
|
||||||
|
- name: Run ESBuild (Proxy)
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm run build-proxy -w @goauthentik/web
|
||||||
npm run build-proxy
|
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
@ -236,6 +193,6 @@ jobs:
|
|||||||
SENTRY_ORG: authentik-security-inc
|
SENTRY_ORG: authentik-security-inc
|
||||||
SENTRY_PROJECT: authentik
|
SENTRY_PROJECT: authentik
|
||||||
with:
|
with:
|
||||||
release: authentik@${{ steps.ev.outputs.version }}
|
version: authentik@${{ steps.ev.outputs.version }}
|
||||||
sourcemaps: "./web/dist"
|
sourcemaps: "./web/dist"
|
||||||
url_prefix: "~/static/dist"
|
url_prefix: "~/static/dist"
|
||||||
|
21
.github/workflows/repo-mirror-cleanup.yml
vendored
21
.github/workflows/repo-mirror-cleanup.yml
vendored
@ -1,21 +0,0 @@
|
|||||||
name: "authentik-repo-mirror-cleanup"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
to_internal:
|
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- if: ${{ env.MIRROR_KEY != '' }}
|
|
||||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
|
||||||
with:
|
|
||||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
|
||||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
|
||||||
args: --tags --force --prune
|
|
||||||
env:
|
|
||||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
|
9
.github/workflows/repo-mirror.yml
vendored
9
.github/workflows/repo-mirror.yml
vendored
@ -11,10 +11,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- if: ${{ env.MIRROR_KEY != '' }}
|
- if: ${{ env.MIRROR_KEY != '' }}
|
||||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
uses: pixta-dev/repository-mirroring-action@v1
|
||||||
with:
|
with:
|
||||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
target_repo_url:
|
||||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
git@github.com:goauthentik/authentik-internal.git
|
||||||
args: --tags --force
|
ssh_private_key:
|
||||||
|
${{ secrets.GH_MIRROR_KEY }}
|
||||||
env:
|
env:
|
||||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
||||||
|
@ -16,7 +16,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
compile:
|
compile:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
@ -33,15 +32,25 @@ jobs:
|
|||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Generate API
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: package.json
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
- name: Prepare Dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Generate TypeScript API
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
- name: run extract
|
- name: Run extract
|
||||||
run: |
|
run: |
|
||||||
uv run make i18n-extract
|
uv run make i18n-extract
|
||||||
- name: run compile
|
- name: Run UV compile
|
||||||
run: |
|
run: |
|
||||||
uv run ak compilemessages
|
uv run ak compilemessages
|
||||||
make web-check-compile
|
- name: Lint Project
|
||||||
|
run: |
|
||||||
|
npm run build-locales -w @goauthentik/web
|
||||||
|
npm run lint:types
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -100,6 +100,9 @@ ipython_config.py
|
|||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
@ -163,6 +166,8 @@ dmypy.json
|
|||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
|
@ -36,12 +36,19 @@ coverage
|
|||||||
*.mdx
|
*.mdx
|
||||||
*.md
|
*.md
|
||||||
|
|
||||||
## Import order matters
|
|
||||||
poly.ts
|
|
||||||
src/locale-codes.ts
|
|
||||||
src/locales/
|
|
||||||
|
|
||||||
# Storybook
|
# Storybook
|
||||||
storybook-static/
|
storybook-static/
|
||||||
.storybook/css-import-maps*
|
.storybook/css-import-maps*
|
||||||
|
|
||||||
|
# JSON Schemas
|
||||||
|
schemas/**/*.json
|
||||||
|
blueprints/**/*.json
|
||||||
|
authentik/**/*.json
|
||||||
|
lifecycle/**/*.json
|
||||||
|
|
||||||
|
# Locales
|
||||||
|
web/src/locale-codes.ts
|
||||||
|
web/src/locales/
|
||||||
|
|
||||||
|
# Wireit's cache
|
||||||
|
.wireit
|
||||||
|
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@ -17,6 +17,6 @@
|
|||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
"redhat.vscode-yaml",
|
"redhat.vscode-yaml",
|
||||||
"Tobermory.es6-string-html",
|
"Tobermory.es6-string-html",
|
||||||
"unifiedjs.vscode-mdx",
|
"unifiedjs.vscode-mdx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -6,15 +6,13 @@
|
|||||||
"!Context scalar",
|
"!Context scalar",
|
||||||
"!Enumerate sequence",
|
"!Enumerate sequence",
|
||||||
"!Env scalar",
|
"!Env scalar",
|
||||||
"!Env sequence",
|
|
||||||
"!Find sequence",
|
"!Find sequence",
|
||||||
"!Format sequence",
|
"!Format sequence",
|
||||||
"!If sequence",
|
"!If sequence",
|
||||||
"!Index scalar",
|
"!Index scalar",
|
||||||
"!KeyOf scalar",
|
"!KeyOf scalar",
|
||||||
"!Value scalar",
|
"!Value scalar",
|
||||||
"!AtIndex scalar",
|
"!AtIndex scalar"
|
||||||
"!ParseJSON scalar"
|
|
||||||
],
|
],
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"typescript.preferences.importModuleSpecifierEnding": "index",
|
"typescript.preferences.importModuleSpecifierEnding": "index",
|
||||||
|
40
.vscode/tasks.json
vendored
40
.vscode/tasks.json
vendored
@ -4,12 +4,7 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik/core: make",
|
"label": "authentik/core: make",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": ["run", "make", "lint-fix", "lint"],
|
||||||
"run",
|
|
||||||
"make",
|
|
||||||
"lint-fix",
|
|
||||||
"lint"
|
|
||||||
],
|
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "new"
|
"panel": "new"
|
||||||
},
|
},
|
||||||
@ -18,11 +13,7 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik/core: run",
|
"label": "authentik/core: run",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": ["run", "ak", "server"],
|
||||||
"run",
|
|
||||||
"ak",
|
|
||||||
"server"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -32,17 +23,13 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik/web: make",
|
"label": "authentik/web: make",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["web"],
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/web: watch",
|
"label": "authentik/web: watch",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["web-watch"],
|
||||||
"web-watch"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -52,26 +39,19 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik: install",
|
"label": "authentik: install",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["install", "-j4"],
|
||||||
"install",
|
|
||||||
"-j4"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/website: make",
|
"label": "authentik/website: make",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["website"],
|
||||||
"website"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/website: watch",
|
"label": "authentik/website: watch",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["website-watch"],
|
||||||
"website-watch"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -81,11 +61,7 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik/api: generate",
|
"label": "authentik/api: generate",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": ["run", "make", "gen"],
|
||||||
"run",
|
|
||||||
"make",
|
|
||||||
"gen"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
54
Dockerfile
54
Dockerfile
@ -1,28 +1,39 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Build webui
|
# Stage 1: Build Node packages
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-packages
|
||||||
|
|
||||||
ARG GIT_BUILD_HASH
|
ARG GIT_BUILD_HASH
|
||||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
WORKDIR /work/web
|
WORKDIR /work
|
||||||
|
|
||||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
|
||||||
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
|
|
||||||
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
|
|
||||||
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
|
|
||||||
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \
|
|
||||||
npm ci --include=dev
|
|
||||||
|
|
||||||
|
COPY ./SECURITY.md /work
|
||||||
|
COPY ./schema.yml /work
|
||||||
|
COPY ./docker-compose.yml /work
|
||||||
|
COPY ./blueprints /work/blueprints/
|
||||||
COPY ./package.json /work
|
COPY ./package.json /work
|
||||||
|
COPY ./package-lock.json /work
|
||||||
|
COPY ./tsconfig.json /work
|
||||||
|
COPY ./packages/ /work/packages/
|
||||||
COPY ./web /work/web/
|
COPY ./web /work/web/
|
||||||
COPY ./website /work/website/
|
COPY ./website /work/website/
|
||||||
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
COPY ./gen-ts-api /work/gen-ts-api/
|
||||||
|
|
||||||
RUN npm run build && \
|
RUN --mount=type=cache,id=npm-node,sharing=shared,target=/root/.npm \
|
||||||
npm run build:sfe
|
npm ci
|
||||||
|
|
||||||
|
RUN cd ./gen-ts-api && npm link
|
||||||
|
|
||||||
|
RUN npm link @goauthentik/api -w @goauthentik/web
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm run build -w @goauthentik/web
|
||||||
|
RUN npm run build -w @goauthentik/web-sfe
|
||||||
|
|
||||||
|
RUN npm run build:api -w @goauthentik/docs
|
||||||
|
RUN npm run build:docusaurus -w @goauthentik/docs
|
||||||
|
|
||||||
# Stage 2: Build go proxy
|
# Stage 2: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
||||||
@ -49,8 +60,8 @@ RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
|||||||
COPY ./cmd /go/src/goauthentik.io/cmd
|
COPY ./cmd /go/src/goauthentik.io/cmd
|
||||||
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
|
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
|
||||||
COPY ./web/static.go /go/src/goauthentik.io/web/static.go
|
COPY ./web/static.go /go/src/goauthentik.io/web/static.go
|
||||||
COPY --from=node-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
|
COPY --from=node-packages /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
|
||||||
COPY --from=node-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
|
COPY --from=node-packages /work/web/security.txt /go/src/goauthentik.io/web/security.txt
|
||||||
COPY ./internal /go/src/goauthentik.io/internal
|
COPY ./internal /go/src/goauthentik.io/internal
|
||||||
COPY ./go.mod /go/src/goauthentik.io/go.mod
|
COPY ./go.mod /go/src/goauthentik.io/go.mod
|
||||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||||
@ -75,9 +86,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||||
|
|
||||||
# Stage 4: Download uv
|
# Stage 4: Download uv
|
||||||
FROM ghcr.io/astral-sh/uv:0.7.17 AS uv
|
FROM ghcr.io/astral-sh/uv:0.7.6 AS uv
|
||||||
# Stage 5: Base python image
|
# Stage 5: Base python image
|
||||||
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base
|
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
|
||||||
|
|
||||||
ENV VENV_PATH="/ak-root/.venv" \
|
ENV VENV_PATH="/ak-root/.venv" \
|
||||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||||
@ -122,7 +133,6 @@ ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
|||||||
|
|
||||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||||
--mount=type=bind,target=packages,src=packages \
|
|
||||||
--mount=type=cache,target=/root/.cache/uv \
|
--mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-install-project --no-dev
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
@ -168,10 +178,10 @@ COPY ./blueprints /blueprints
|
|||||||
COPY ./lifecycle/ /lifecycle
|
COPY ./lifecycle/ /lifecycle
|
||||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||||
COPY --from=go-builder /go/authentik /bin/authentik
|
COPY --from=go-builder /go/authentik /bin/authentik
|
||||||
COPY ./packages/ /ak-root/packages
|
|
||||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||||
COPY --from=node-builder /work/web/dist/ /web/dist/
|
COPY --from=node-packages /work/web/dist/ /web/dist/
|
||||||
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
COPY --from=node-packages /work/web/authentik/ /web/authentik/
|
||||||
|
COPY --from=node-packages /work/website/build/ /website/help/
|
||||||
COPY --from=geoip /usr/share/GeoIP /geoip
|
COPY --from=geoip /usr/share/GeoIP /geoip
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
|
52
Makefile
52
Makefile
@ -1,12 +1,12 @@
|
|||||||
.PHONY: gen dev-reset all clean test web website
|
.PHONY: gen dev-reset all clean test web website
|
||||||
|
|
||||||
SHELL := /usr/bin/env bash
|
SHELL := /bin/bash
|
||||||
.SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail
|
.SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail
|
||||||
PWD = $(shell pwd)
|
PWD = $(shell pwd)
|
||||||
UID = $(shell id -u)
|
UID = $(shell id -u)
|
||||||
GID = $(shell id -g)
|
GID = $(shell id -g)
|
||||||
NPM_VERSION = $(shell python -m scripts.generate_semver)
|
NPM_VERSION = $(shell python -m scripts.generate_semver)
|
||||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
PY_SOURCES = authentik tests scripts lifecycle .github
|
||||||
DOCKER_IMAGE ?= "authentik:test"
|
DOCKER_IMAGE ?= "authentik:test"
|
||||||
|
|
||||||
GEN_API_TS = gen-ts-api
|
GEN_API_TS = gen-ts-api
|
||||||
@ -73,7 +73,7 @@ core-i18n-extract:
|
|||||||
--ignore website \
|
--ignore website \
|
||||||
-l en
|
-l en
|
||||||
|
|
||||||
install: web-install website-install core-install ## Install all requires dependencies for `web`, `website` and `core`
|
install: npm-install core-install ## Install all requires dependencies for `web`, `website` and `core`
|
||||||
|
|
||||||
dev-drop-db:
|
dev-drop-db:
|
||||||
dropdb -U ${pg_user} -h ${pg_host} ${pg_name}
|
dropdb -U ${pg_user} -h ${pg_host} ${pg_name}
|
||||||
@ -86,10 +86,6 @@ dev-create-db:
|
|||||||
|
|
||||||
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
|
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
|
||||||
|
|
||||||
update-test-mmdb: ## Update test GeoIP and ASN Databases
|
|
||||||
curl -L https://raw.githubusercontent.com/maxmind/MaxMind-DB/refs/heads/main/test-data/GeoLite2-ASN-Test.mmdb -o ${PWD}/tests/GeoLite2-ASN-Test.mmdb
|
|
||||||
curl -L https://raw.githubusercontent.com/maxmind/MaxMind-DB/refs/heads/main/test-data/GeoLite2-City-Test.mmdb -o ${PWD}/tests/GeoLite2-City-Test.mmdb
|
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
## API Schema
|
## API Schema
|
||||||
#########################
|
#########################
|
||||||
@ -98,7 +94,7 @@ gen-build: ## Extract the schema from the database
|
|||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true \
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_TENANTS__ENABLED=true \
|
||||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||||
uv run ak make_blueprint_schema --file blueprints/schema.json
|
uv run ak make_blueprint_schema > blueprints/schema.json
|
||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true \
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_TENANTS__ENABLED=true \
|
||||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||||
@ -150,9 +146,8 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
|||||||
--additional-properties=npmVersion=${NPM_VERSION} \
|
--additional-properties=npmVersion=${NPM_VERSION} \
|
||||||
--git-repo-id authentik \
|
--git-repo-id authentik \
|
||||||
--git-user-id goauthentik
|
--git-user-id goauthentik
|
||||||
|
cd ./${GEN_API_TS} && npm link
|
||||||
cd ${PWD}/${GEN_API_TS} && npm link
|
npm link @goauthentik/api -w @goauthentik/web
|
||||||
cd ${PWD}/web && npm link @goauthentik/api
|
|
||||||
|
|
||||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||||
docker run \
|
docker run \
|
||||||
@ -187,38 +182,34 @@ gen: gen-build gen-client-ts
|
|||||||
## Web
|
## Web
|
||||||
#########################
|
#########################
|
||||||
|
|
||||||
web-build: web-install ## Build the Authentik UI
|
web-build: npm-install ## Build the Authentik UI
|
||||||
cd web && npm run build
|
npm run build -w @goauthentik/web
|
||||||
|
|
||||||
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
||||||
|
|
||||||
web-install: ## Install the necessary libraries to build the Authentik UI
|
npm-install: ## Install the necessary libraries to build the Authentik UI
|
||||||
cd web && npm ci
|
npm ci
|
||||||
|
|
||||||
web-test: ## Run tests for the Authentik UI
|
web-test: ## Run tests for the Authentik UI
|
||||||
cd web && npm run test
|
npm run test -w @goauthentik/web
|
||||||
|
|
||||||
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
|
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
|
||||||
rm -rf web/dist/
|
npm run watch -w @goauthentik/web
|
||||||
mkdir web/dist/
|
|
||||||
touch web/dist/.gitkeep
|
|
||||||
cd web && npm run watch
|
|
||||||
|
|
||||||
web-storybook-watch: ## Build and run the storybook documentation server
|
web-storybook-watch: ## Build and run the storybook documentation server
|
||||||
cd web && npm run storybook
|
npm run storybook -w @goauthentik/web
|
||||||
|
|
||||||
web-lint-fix:
|
web-lint-fix:
|
||||||
cd web && npm run prettier
|
npm run prettier -w @goauthentik/web
|
||||||
|
|
||||||
web-lint:
|
web-lint:
|
||||||
cd web && npm run lint
|
npm run lint -w @goauthentik/web
|
||||||
cd web && npm run lit-analyse
|
|
||||||
|
|
||||||
web-check-compile:
|
web-check-compile:
|
||||||
cd web && npm run tsc
|
npm run lint:types
|
||||||
|
|
||||||
web-i18n-extract:
|
web-i18n-extract:
|
||||||
cd web && npm run extract-locales
|
npm run extract-locales -w @goauthentik/web
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
## Website
|
## Website
|
||||||
@ -226,17 +217,14 @@ web-i18n-extract:
|
|||||||
|
|
||||||
website: website-lint-fix website-build ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it
|
website: website-lint-fix website-build ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it
|
||||||
|
|
||||||
website-install:
|
|
||||||
cd website && npm ci
|
|
||||||
|
|
||||||
website-lint-fix: lint-codespell
|
website-lint-fix: lint-codespell
|
||||||
cd website && npm run prettier
|
npm run prettier --prefix website
|
||||||
|
|
||||||
website-build:
|
website-build:
|
||||||
cd website && npm run build
|
npm run build --prefix website
|
||||||
|
|
||||||
website-watch: ## Build and watch the documentation website, updating automatically
|
website-watch: ## Build and watch the documentation website, updating automatically
|
||||||
cd website && npm run watch
|
npm run watch --prefix website
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
## Docker
|
## Docker
|
||||||
|
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | --------- |
|
| --------- | --------- |
|
||||||
|
| 2025.2.x | ✅ |
|
||||||
| 2025.4.x | ✅ |
|
| 2025.4.x | ✅ |
|
||||||
| 2025.6.x | ✅ |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2025.6.3"
|
__version__ = "2025.4.1"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
79
authentik/admin/api/metrics.py
Normal file
79
authentik/admin/api/metrics.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"""authentik administration metrics"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
|
from guardian.shortcuts import get_objects_for_user
|
||||||
|
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.events.models import EventAction
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinateSerializer(PassiveSerializer):
|
||||||
|
"""Coordinates for diagrams"""
|
||||||
|
|
||||||
|
x_cord = IntegerField(read_only=True)
|
||||||
|
y_cord = IntegerField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginMetricsSerializer(PassiveSerializer):
|
||||||
|
"""Login Metrics per 1h"""
|
||||||
|
|
||||||
|
logins = SerializerMethodField()
|
||||||
|
logins_failed = SerializerMethodField()
|
||||||
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_logins(self, _):
|
||||||
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.LOGIN
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_logins_failed(self, _):
|
||||||
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.LOGIN_FAILED
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_authorizations(self, _):
|
||||||
|
"""Get successful authorizations per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.AUTHORIZE_APPLICATION
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdministrationMetricsViewSet(APIView):
|
||||||
|
"""Login Metrics per 1h"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
"""Login Metrics per 1h"""
|
||||||
|
serializer = LoginMetricsSerializer(True)
|
||||||
|
serializer.context["user"] = request.user
|
||||||
|
return Response(serializer.data)
|
@ -1,7 +1,6 @@
|
|||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django_tenants.utils import get_public_schema_name
|
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
@ -14,7 +13,6 @@ from authentik import __version__, get_build_hash
|
|||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
|
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
from authentik.tenants.utils import get_current_tenant
|
|
||||||
|
|
||||||
|
|
||||||
class VersionSerializer(PassiveSerializer):
|
class VersionSerializer(PassiveSerializer):
|
||||||
@ -37,11 +35,9 @@ class VersionSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
def get_version_latest(self, _) -> str:
|
def get_version_latest(self, _) -> str:
|
||||||
"""Get latest version from cache"""
|
"""Get latest version from cache"""
|
||||||
if get_current_tenant().schema_name == get_public_schema_name():
|
|
||||||
return __version__
|
|
||||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||||
if not version_in_cache: # pragma: no cover
|
if not version_in_cache: # pragma: no cover
|
||||||
update_latest_version.send()
|
update_latest_version.delay()
|
||||||
return __version__
|
return __version__
|
||||||
return version_in_cache
|
return version_in_cache
|
||||||
|
|
||||||
|
57
authentik/admin/api/workers.py
Normal file
57
authentik/admin/api/workers.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""authentik administration overview"""
|
||||||
|
|
||||||
|
from socket import gethostname
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
|
from packaging.version import parse
|
||||||
|
from rest_framework.fields import BooleanField, CharField
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from authentik import get_full_version
|
||||||
|
from authentik.rbac.permissions import HasPermission
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerView(APIView):
|
||||||
|
"""Get currently connected worker count."""
|
||||||
|
|
||||||
|
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses=inline_serializer(
|
||||||
|
"Worker",
|
||||||
|
fields={
|
||||||
|
"worker_id": CharField(),
|
||||||
|
"version": CharField(),
|
||||||
|
"version_matching": BooleanField(),
|
||||||
|
},
|
||||||
|
many=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
"""Get currently connected worker count."""
|
||||||
|
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
|
||||||
|
our_version = parse(get_full_version())
|
||||||
|
response = []
|
||||||
|
for worker in raw:
|
||||||
|
key = list(worker.keys())[0]
|
||||||
|
version = worker[key].get("version")
|
||||||
|
version_matching = False
|
||||||
|
if version:
|
||||||
|
version_matching = parse(version) == our_version
|
||||||
|
response.append(
|
||||||
|
{"worker_id": key, "version": version, "version_matching": version_matching}
|
||||||
|
)
|
||||||
|
# In debug we run with `task_always_eager`, so tasks are ran on the main process
|
||||||
|
if settings.DEBUG: # pragma: no cover
|
||||||
|
response.append(
|
||||||
|
{
|
||||||
|
"worker_id": f"authentik-debug@{gethostname()}",
|
||||||
|
"version": get_full_version(),
|
||||||
|
"version_matching": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response(response)
|
@ -3,9 +3,6 @@
|
|||||||
from prometheus_client import Info
|
from prometheus_client import Info
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.lib.utils.time import fqdn_rand
|
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||||
|
|
||||||
@ -17,31 +14,3 @@ class AuthentikAdminConfig(ManagedAppConfig):
|
|||||||
label = "authentik_admin"
|
label = "authentik_admin"
|
||||||
verbose_name = "authentik Admin"
|
verbose_name = "authentik Admin"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
@ManagedAppConfig.reconcile_global
|
|
||||||
def clear_update_notifications(self):
|
|
||||||
"""Clear update notifications on startup if the notification was for the version
|
|
||||||
we're running now."""
|
|
||||||
from packaging.version import parse
|
|
||||||
|
|
||||||
from authentik.admin.tasks import LOCAL_VERSION
|
|
||||||
from authentik.events.models import EventAction, Notification
|
|
||||||
|
|
||||||
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
|
|
||||||
if "new_version" not in notification.event.context:
|
|
||||||
continue
|
|
||||||
notification_version = notification.event.context["new_version"]
|
|
||||||
if LOCAL_VERSION >= parse(notification_version):
|
|
||||||
notification.delete()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def global_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.admin.tasks import update_latest_version
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=update_latest_version,
|
|
||||||
crontab=f"{fqdn_rand('admin_latest_version')} * * * *",
|
|
||||||
paused=CONFIG.get_bool("disable_update_check"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
13
authentik/admin/settings.py
Normal file
13
authentik/admin/settings.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""authentik admin settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"admin_latest_version": {
|
||||||
|
"task": "authentik.admin.tasks.update_latest_version",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
}
|
||||||
|
}
|
35
authentik/admin/signals.py
Normal file
35
authentik/admin/signals.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""admin signals"""
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from packaging.version import parse
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from authentik import get_full_version
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
from authentik.root.monitoring import monitoring_set
|
||||||
|
|
||||||
|
GAUGE_WORKERS = Gauge(
|
||||||
|
"authentik_admin_workers",
|
||||||
|
"Currently connected workers, their versions and if they are the same version as authentik",
|
||||||
|
["version", "version_matched"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_version = parse(get_full_version())
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(monitoring_set)
|
||||||
|
def monitoring_set_workers(sender, **kwargs):
|
||||||
|
"""Set worker gauge"""
|
||||||
|
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
|
||||||
|
worker_version_count = {}
|
||||||
|
for worker in raw:
|
||||||
|
key = list(worker.keys())[0]
|
||||||
|
version = worker[key].get("version")
|
||||||
|
version_matching = False
|
||||||
|
if version:
|
||||||
|
version_matching = parse(version) == _version
|
||||||
|
worker_version_count.setdefault(version, {"count": 0, "matching": version_matching})
|
||||||
|
worker_version_count[version]["count"] += 1
|
||||||
|
for version, stats in worker_version_count.items():
|
||||||
|
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
|
@ -1,19 +1,19 @@
|
|||||||
"""authentik admin tasks"""
|
"""authentik admin tasks"""
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_dramatiq_postgres.middleware import CurrentTask
|
|
||||||
from dramatiq import actor
|
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import __version__, get_build_hash
|
from authentik import __version__, get_build_hash
|
||||||
from authentik.admin.apps import PROM_INFO
|
from authentik.admin.apps import PROM_INFO
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
|
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.utils.http import get_http_session
|
from authentik.lib.utils.http import get_http_session
|
||||||
from authentik.tasks.models import Task
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
VERSION_NULL = "0.0.0"
|
VERSION_NULL = "0.0.0"
|
||||||
@ -33,12 +33,27 @@ def _set_prom_info():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Update latest version info."))
|
@CELERY_APP.task(
|
||||||
def update_latest_version():
|
throws=(DatabaseError, ProgrammingError, InternalError),
|
||||||
self: Task = CurrentTask.get_task()
|
)
|
||||||
|
def clear_update_notifications():
|
||||||
|
"""Clear update notifications on startup if the notification was for the version
|
||||||
|
we're running now."""
|
||||||
|
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
|
||||||
|
if "new_version" not in notification.event.context:
|
||||||
|
continue
|
||||||
|
notification_version = notification.event.context["new_version"]
|
||||||
|
if LOCAL_VERSION >= parse(notification_version):
|
||||||
|
notification.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
|
@prefill_task
|
||||||
|
def update_latest_version(self: SystemTask):
|
||||||
|
"""Update latest version info"""
|
||||||
if CONFIG.get_bool("disable_update_check"):
|
if CONFIG.get_bool("disable_update_check"):
|
||||||
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
|
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
|
||||||
self.info("Version check disabled.")
|
self.set_status(TaskStatus.WARNING, "Version check disabled.")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
response = get_http_session().get(
|
response = get_http_session().get(
|
||||||
@ -48,7 +63,7 @@ def update_latest_version():
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
upstream_version = data.get("stable", {}).get("version")
|
upstream_version = data.get("stable", {}).get("version")
|
||||||
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
|
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
|
||||||
self.info("Successfully updated latest Version")
|
self.set_status(TaskStatus.SUCCESSFUL, "Successfully updated latest Version")
|
||||||
_set_prom_info()
|
_set_prom_info()
|
||||||
# Check if upstream version is newer than what we're running,
|
# Check if upstream version is newer than what we're running,
|
||||||
# and if no event exists yet, create one.
|
# and if no event exists yet, create one.
|
||||||
@ -71,7 +86,7 @@ def update_latest_version():
|
|||||||
).save()
|
).save()
|
||||||
except (RequestException, IndexError) as exc:
|
except (RequestException, IndexError) as exc:
|
||||||
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
|
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
|
||||||
raise exc
|
self.set_error(exc)
|
||||||
|
|
||||||
|
|
||||||
_set_prom_info()
|
_set_prom_info()
|
||||||
|
@ -29,6 +29,18 @@ class TestAdminAPI(TestCase):
|
|||||||
body = loads(response.content)
|
body = loads(response.content)
|
||||||
self.assertEqual(body["version_current"], __version__)
|
self.assertEqual(body["version_current"], __version__)
|
||||||
|
|
||||||
|
def test_workers(self):
|
||||||
|
"""Test Workers API"""
|
||||||
|
response = self.client.get(reverse("authentik_api:admin_workers"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body), 0)
|
||||||
|
|
||||||
|
def test_metrics(self):
|
||||||
|
"""Test metrics API"""
|
||||||
|
response = self.client.get(reverse("authentik_api:admin_metrics"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_apps(self):
|
def test_apps(self):
|
||||||
"""Test apps API"""
|
"""Test apps API"""
|
||||||
response = self.client.get(reverse("authentik_api:apps-list"))
|
response = self.client.get(reverse("authentik_api:apps-list"))
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
"""test admin tasks"""
|
"""test admin tasks"""
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.admin.tasks import (
|
from authentik.admin.tasks import (
|
||||||
VERSION_CACHE_KEY,
|
VERSION_CACHE_KEY,
|
||||||
|
clear_update_notifications,
|
||||||
update_latest_version,
|
update_latest_version,
|
||||||
)
|
)
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
@ -30,7 +30,7 @@ class TestAdminTasks(TestCase):
|
|||||||
"""Test Update checker with valid response"""
|
"""Test Update checker with valid response"""
|
||||||
with Mocker() as mocker, CONFIG.patch("disable_update_check", False):
|
with Mocker() as mocker, CONFIG.patch("disable_update_check", False):
|
||||||
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
|
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
|
||||||
update_latest_version.send()
|
update_latest_version.delay().get()
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
@ -40,7 +40,7 @@ class TestAdminTasks(TestCase):
|
|||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
# test that a consecutive check doesn't create a duplicate event
|
# test that a consecutive check doesn't create a duplicate event
|
||||||
update_latest_version.send()
|
update_latest_version.delay().get()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(
|
len(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
@ -56,7 +56,7 @@ class TestAdminTasks(TestCase):
|
|||||||
"""Test Update checker with invalid response"""
|
"""Test Update checker with invalid response"""
|
||||||
with Mocker() as mocker:
|
with Mocker() as mocker:
|
||||||
mocker.get("https://version.goauthentik.io/version.json", status_code=400)
|
mocker.get("https://version.goauthentik.io/version.json", status_code=400)
|
||||||
update_latest_version.send()
|
update_latest_version.delay().get()
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
@ -67,19 +67,17 @@ class TestAdminTasks(TestCase):
|
|||||||
def test_version_disabled(self):
|
def test_version_disabled(self):
|
||||||
"""Test Update checker while its disabled"""
|
"""Test Update checker while its disabled"""
|
||||||
with CONFIG.patch("disable_update_check", True):
|
with CONFIG.patch("disable_update_check", True):
|
||||||
update_latest_version.send()
|
update_latest_version.delay().get()
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
||||||
|
|
||||||
def test_clear_update_notifications(self):
|
def test_clear_update_notifications(self):
|
||||||
"""Test clear of previous notification"""
|
"""Test clear of previous notification"""
|
||||||
admin_config = apps.get_app_config("authentik_admin")
|
|
||||||
Event.objects.create(
|
Event.objects.create(
|
||||||
action=EventAction.UPDATE_AVAILABLE,
|
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
|
||||||
context={"new_version": "99999999.9999999.9999999"},
|
|
||||||
)
|
)
|
||||||
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
|
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
|
||||||
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
|
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
|
||||||
admin_config.clear_update_notifications()
|
clear_update_notifications()
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"
|
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"
|
||||||
|
@ -3,14 +3,22 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from authentik.admin.api.meta import AppsViewSet, ModelViewSet
|
from authentik.admin.api.meta import AppsViewSet, ModelViewSet
|
||||||
|
from authentik.admin.api.metrics import AdministrationMetricsViewSet
|
||||||
from authentik.admin.api.system import SystemView
|
from authentik.admin.api.system import SystemView
|
||||||
from authentik.admin.api.version import VersionView
|
from authentik.admin.api.version import VersionView
|
||||||
from authentik.admin.api.version_history import VersionHistoryViewSet
|
from authentik.admin.api.version_history import VersionHistoryViewSet
|
||||||
|
from authentik.admin.api.workers import WorkerView
|
||||||
|
|
||||||
api_urlpatterns = [
|
api_urlpatterns = [
|
||||||
("admin/apps", AppsViewSet, "apps"),
|
("admin/apps", AppsViewSet, "apps"),
|
||||||
("admin/models", ModelViewSet, "models"),
|
("admin/models", ModelViewSet, "models"),
|
||||||
|
path(
|
||||||
|
"admin/metrics/",
|
||||||
|
AdministrationMetricsViewSet.as_view(),
|
||||||
|
name="admin_metrics",
|
||||||
|
),
|
||||||
path("admin/version/", VersionView.as_view(), name="admin_version"),
|
path("admin/version/", VersionView.as_view(), name="admin_version"),
|
||||||
("admin/version/history", VersionHistoryViewSet, "version_history"),
|
("admin/version/history", VersionHistoryViewSet, "version_history"),
|
||||||
|
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
|
||||||
path("admin/system/", SystemView.as_view(), name="admin_system"),
|
path("admin/system/", SystemView.as_view(), name="admin_system"),
|
||||||
]
|
]
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
"""authentik API AppConfig"""
|
"""authentik API AppConfig"""
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AuthentikAPIConfig(ManagedAppConfig):
|
class AuthentikAPIConfig(AppConfig):
|
||||||
"""authentik API Config"""
|
"""authentik API Config"""
|
||||||
|
|
||||||
name = "authentik.api"
|
name = "authentik.api"
|
||||||
label = "authentik_api"
|
label = "authentik_api"
|
||||||
mountpoint = "api/"
|
mountpoint = "api/"
|
||||||
verbose_name = "authentik API"
|
verbose_name = "authentik API"
|
||||||
default = True
|
|
||||||
|
@ -39,7 +39,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
"""Ensure the path (if set) specified is retrievable"""
|
"""Ensure the path (if set) specified is retrievable"""
|
||||||
if path == "" or path.startswith(OCI_PREFIX):
|
if path == "" or path.startswith(OCI_PREFIX):
|
||||||
return path
|
return path
|
||||||
files: list[dict] = blueprints_find_dict.send().get_result(block=True)
|
files: list[dict] = blueprints_find_dict.delay().get()
|
||||||
if path not in [file["path"] for file in files]:
|
if path not in [file["path"] for file in files]:
|
||||||
raise ValidationError(_("Blueprint file does not exist"))
|
raise ValidationError(_("Blueprint file does not exist"))
|
||||||
return path
|
return path
|
||||||
@ -115,7 +115,7 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||||
def available(self, request: Request) -> Response:
|
def available(self, request: Request) -> Response:
|
||||||
"""Get blueprints"""
|
"""Get blueprints"""
|
||||||
files: list[dict] = blueprints_find_dict.send().get_result(block=True)
|
files: list[dict] = blueprints_find_dict.delay().get()
|
||||||
return Response(files)
|
return Response(files)
|
||||||
|
|
||||||
@permission_required("authentik_blueprints.view_blueprintinstance")
|
@permission_required("authentik_blueprints.view_blueprintinstance")
|
||||||
@ -129,5 +129,5 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
|||||||
def apply(self, request: Request, *args, **kwargs) -> Response:
|
def apply(self, request: Request, *args, **kwargs) -> Response:
|
||||||
"""Apply a blueprint"""
|
"""Apply a blueprint"""
|
||||||
blueprint = self.get_object()
|
blueprint = self.get_object()
|
||||||
apply_blueprint.send_with_options(args=(blueprint.pk,), rel_obj=blueprint)
|
apply_blueprint.delay(str(blueprint.pk)).get()
|
||||||
return self.retrieve(request, *args, **kwargs)
|
return self.retrieve(request, *args, **kwargs)
|
||||||
|
@ -6,12 +6,9 @@ from inspect import ismethod
|
|||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||||
from dramatiq.broker import get_broker
|
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
|
|
||||||
from authentik.lib.utils.time import fqdn_rand
|
|
||||||
from authentik.root.signals import startup
|
from authentik.root.signals import startup
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
|
|
||||||
class ManagedAppConfig(AppConfig):
|
class ManagedAppConfig(AppConfig):
|
||||||
@ -37,7 +34,7 @@ class ManagedAppConfig(AppConfig):
|
|||||||
|
|
||||||
def import_related(self):
|
def import_related(self):
|
||||||
"""Automatically import related modules which rely on just being imported
|
"""Automatically import related modules which rely on just being imported
|
||||||
to register themselves (mainly django signals and tasks)"""
|
to register themselves (mainly django signals and celery tasks)"""
|
||||||
|
|
||||||
def import_relative(rel_module: str):
|
def import_relative(rel_module: str):
|
||||||
try:
|
try:
|
||||||
@ -83,16 +80,6 @@ class ManagedAppConfig(AppConfig):
|
|||||||
func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_GLOBAL_CATEGORY
|
func._authentik_managed_reconcile = ManagedAppConfig.RECONCILE_GLOBAL_CATEGORY
|
||||||
return func
|
return func
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
"""Get a list of schedule specs that must exist in each tenant"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def global_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
"""Get a list of schedule specs that must exist in the default tenant"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _reconcile_tenant(self) -> None:
|
def _reconcile_tenant(self) -> None:
|
||||||
"""reconcile ourselves for tenanted methods"""
|
"""reconcile ourselves for tenanted methods"""
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@ -113,12 +100,8 @@ class ManagedAppConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
from django_tenants.utils import get_public_schema_name, schema_context
|
from django_tenants.utils import get_public_schema_name, schema_context
|
||||||
|
|
||||||
try:
|
|
||||||
with schema_context(get_public_schema_name()):
|
with schema_context(get_public_schema_name()):
|
||||||
self._reconcile(self.RECONCILE_GLOBAL_CATEGORY)
|
self._reconcile(self.RECONCILE_GLOBAL_CATEGORY)
|
||||||
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
|
||||||
self.logger.debug("Failed to access database to run reconcile", exc=exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
||||||
@ -129,29 +112,19 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
|
|||||||
verbose_name = "authentik Blueprints"
|
verbose_name = "authentik Blueprints"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
|
@ManagedAppConfig.reconcile_global
|
||||||
|
def load_blueprints_v1_tasks(self):
|
||||||
|
"""Load v1 tasks"""
|
||||||
|
self.import_module("authentik.blueprints.v1.tasks")
|
||||||
|
|
||||||
|
@ManagedAppConfig.reconcile_tenant
|
||||||
|
def blueprints_discovery(self):
|
||||||
|
"""Run blueprint discovery"""
|
||||||
|
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints
|
||||||
|
|
||||||
|
blueprints_discovery.delay()
|
||||||
|
clear_failed_blueprints.delay()
|
||||||
|
|
||||||
def import_models(self):
|
def import_models(self):
|
||||||
super().import_models()
|
super().import_models()
|
||||||
self.import_module("authentik.blueprints.v1.meta.apply_blueprint")
|
self.import_module("authentik.blueprints.v1.meta.apply_blueprint")
|
||||||
|
|
||||||
@ManagedAppConfig.reconcile_global
|
|
||||||
def tasks_middlewares(self):
|
|
||||||
from authentik.blueprints.v1.tasks import BlueprintWatcherMiddleware
|
|
||||||
|
|
||||||
get_broker().add_middleware(BlueprintWatcherMiddleware())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=blueprints_discovery,
|
|
||||||
crontab=f"{fqdn_rand('blueprints_v1_discover')} * * * *",
|
|
||||||
send_on_startup=True,
|
|
||||||
),
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=clear_failed_blueprints,
|
|
||||||
crontab=f"{fqdn_rand('blueprints_v1_cleanup')} * * * *",
|
|
||||||
send_on_startup=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
@ -72,33 +72,20 @@ class Command(BaseCommand):
|
|||||||
"additionalProperties": True,
|
"additionalProperties": True,
|
||||||
},
|
},
|
||||||
"entries": {
|
"entries": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
"items": {
|
||||||
},
|
"oneOf": [],
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
"$defs": {},
|
||||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("--file", type=str)
|
|
||||||
|
|
||||||
@no_translations
|
@no_translations
|
||||||
def handle(self, *args, file: str, **options):
|
def handle(self, *args, **options):
|
||||||
"""Generate JSON Schema for blueprints"""
|
"""Generate JSON Schema for blueprints"""
|
||||||
self.build()
|
self.build()
|
||||||
with open(file, "w") as _schema:
|
self.stdout.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||||
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def json_default(value: Any) -> Any:
|
def json_default(value: Any) -> Any:
|
||||||
@ -125,7 +112,7 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
|
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
|
||||||
self.schema["$defs"]["blueprint_entry"]["oneOf"].append(
|
self.schema["properties"]["entries"]["items"]["oneOf"].append(
|
||||||
self.template_entry(model_path, model, serializer)
|
self.template_entry(model_path, model, serializer)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -147,7 +134,7 @@ class Command(BaseCommand):
|
|||||||
"id": {"type": "string"},
|
"id": {"type": "string"},
|
||||||
"state": {
|
"state": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": sorted([s.value for s in BlueprintEntryDesiredState]),
|
"enum": [s.value for s in BlueprintEntryDesiredState],
|
||||||
"default": "present",
|
"default": "present",
|
||||||
},
|
},
|
||||||
"conditions": {"type": "array", "items": {"type": "boolean"}},
|
"conditions": {"type": "array", "items": {"type": "boolean"}},
|
||||||
@ -218,7 +205,7 @@ class Command(BaseCommand):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["permission"],
|
"required": ["permission"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"permission": {"type": "string", "enum": sorted(perms)},
|
"permission": {"type": "string", "enum": perms},
|
||||||
"user": {"type": "integer"},
|
"user": {"type": "integer"},
|
||||||
"role": {"type": "string"},
|
"role": {"type": "string"},
|
||||||
},
|
},
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -72,13 +71,6 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
managed_models = ArrayField(models.TextField(), default=list)
|
managed_models = ArrayField(models.TextField(), default=list)
|
||||||
|
|
||||||
# Manual link to tasks instead of using TasksModel because of loop imports
|
|
||||||
tasks = GenericRelation(
|
|
||||||
"authentik_tasks.Task",
|
|
||||||
content_type_field="rel_obj_content_type",
|
|
||||||
object_id_field="rel_obj_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Blueprint Instance")
|
verbose_name = _("Blueprint Instance")
|
||||||
verbose_name_plural = _("Blueprint Instances")
|
verbose_name_plural = _("Blueprint Instances")
|
||||||
|
18
authentik/blueprints/settings.py
Normal file
18
authentik/blueprints/settings.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"""blueprint Settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"blueprints_v1_discover": {
|
||||||
|
"task": "authentik.blueprints.v1.tasks.blueprints_discovery",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
"blueprints_v1_cleanup": {
|
||||||
|
"task": "authentik.blueprints.v1.tasks.clear_failed_blueprints",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("blueprints_v1_cleanup"), hour="*"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
@ -1,2 +0,0 @@
|
|||||||
# Import all v1 tasks for auto task discovery
|
|
||||||
from authentik.blueprints.v1.tasks import * # noqa: F403
|
|
@ -1,6 +1,5 @@
|
|||||||
version: 1
|
version: 1
|
||||||
entries:
|
entries:
|
||||||
foo:
|
|
||||||
- identifiers:
|
- identifiers:
|
||||||
name: "%(id)s"
|
name: "%(id)s"
|
||||||
slug: "%(id)s"
|
slug: "%(id)s"
|
||||||
|
@ -37,7 +37,6 @@ entries:
|
|||||||
- attrs:
|
- attrs:
|
||||||
attributes:
|
attributes:
|
||||||
env_null: !Env [bar-baz, null]
|
env_null: !Env [bar-baz, null]
|
||||||
json_parse: !ParseJSON '{"foo": "bar"}'
|
|
||||||
policy_pk1:
|
policy_pk1:
|
||||||
!Format [
|
!Format [
|
||||||
"%s-%s",
|
"%s-%s",
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
|
||||||
from authentik.lib.utils.reflection import get_apps
|
|
||||||
|
|
||||||
|
|
||||||
class TestManagedAppConfig(TestCase):
|
|
||||||
def test_apps_use_managed_app_config(self):
|
|
||||||
for app in get_apps():
|
|
||||||
if app.name.startswith("authentik.enterprise"):
|
|
||||||
self.assertIn(EnterpriseConfig, app.__class__.__bases__)
|
|
||||||
else:
|
|
||||||
self.assertIn(ManagedAppConfig, app.__class__.__bases__)
|
|
@ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable:
|
|||||||
|
|
||||||
|
|
||||||
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
|
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
|
||||||
if "local" in str(blueprint_file) or "testing" in str(blueprint_file):
|
if "local" in str(blueprint_file):
|
||||||
continue
|
continue
|
||||||
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))
|
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))
|
||||||
|
@ -5,6 +5,7 @@ from collections.abc import Callable
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from authentik.blueprints.v1.importer import is_model_allowed
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.providers.oauth2.models import RefreshToken
|
from authentik.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
@ -21,13 +22,10 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable:
|
|||||||
return
|
return
|
||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||||
# Models that have subclasses don't have to have a serializer
|
|
||||||
if len(test_model.__subclasses__()) > 0:
|
|
||||||
return
|
|
||||||
self.assertIsNotNone(model_class.serializer)
|
self.assertIsNotNone(model_class.serializer)
|
||||||
if model_class.serializer.Meta().model == RefreshToken:
|
if model_class.serializer.Meta().model == RefreshToken:
|
||||||
return
|
return
|
||||||
self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model))
|
self.assertEqual(model_class.serializer.Meta().model, test_model)
|
||||||
|
|
||||||
return tester
|
return tester
|
||||||
|
|
||||||
@ -36,6 +34,6 @@ for app in apps.get_app_configs():
|
|||||||
if not app.label.startswith("authentik"):
|
if not app.label.startswith("authentik"):
|
||||||
continue
|
continue
|
||||||
for model in app.get_models():
|
for model in app.get_models():
|
||||||
if not issubclass(model, SerializerModel):
|
if not is_model_allowed(model):
|
||||||
continue
|
continue
|
||||||
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
||||||
|
@ -215,7 +215,6 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
},
|
},
|
||||||
"nested_context": "context-nested-value",
|
"nested_context": "context-nested-value",
|
||||||
"env_null": None,
|
"env_null": None,
|
||||||
"json_parse": {"foo": "bar"},
|
|
||||||
"at_index_sequence": "foo",
|
"at_index_sequence": "foo",
|
||||||
"at_index_sequence_default": "non existent",
|
"at_index_sequence_default": "non existent",
|
||||||
"at_index_mapping": 2,
|
"at_index_mapping": 2,
|
||||||
|
@ -54,7 +54,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
|||||||
file.seek(0)
|
file.seek(0)
|
||||||
file_hash = sha512(file.read().encode()).hexdigest()
|
file_hash = sha512(file.read().encode()).hexdigest()
|
||||||
file.flush()
|
file.flush()
|
||||||
blueprints_discovery.send()
|
blueprints_discovery()
|
||||||
instance = BlueprintInstance.objects.filter(name=blueprint_id).first()
|
instance = BlueprintInstance.objects.filter(name=blueprint_id).first()
|
||||||
self.assertEqual(instance.last_applied_hash, file_hash)
|
self.assertEqual(instance.last_applied_hash, file_hash)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -82,7 +82,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
file.flush()
|
file.flush()
|
||||||
blueprints_discovery.send()
|
blueprints_discovery()
|
||||||
blueprint = BlueprintInstance.objects.filter(name="foo").first()
|
blueprint = BlueprintInstance.objects.filter(name="foo").first()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
blueprint.last_applied_hash,
|
blueprint.last_applied_hash,
|
||||||
@ -107,7 +107,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
file.flush()
|
file.flush()
|
||||||
blueprints_discovery.send()
|
blueprints_discovery()
|
||||||
blueprint.refresh_from_db()
|
blueprint.refresh_from_db()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
blueprint.last_applied_hash,
|
blueprint.last_applied_hash,
|
||||||
|
@ -6,7 +6,6 @@ from copy import copy
|
|||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from json import JSONDecodeError, loads
|
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Literal, Union
|
from typing import Any, Literal, Union
|
||||||
@ -192,18 +191,11 @@ class Blueprint:
|
|||||||
"""Dataclass used for a full export"""
|
"""Dataclass used for a full export"""
|
||||||
|
|
||||||
version: int = field(default=1)
|
version: int = field(default=1)
|
||||||
entries: list[BlueprintEntry] | dict[str, list[BlueprintEntry]] = field(default_factory=list)
|
entries: list[BlueprintEntry] = field(default_factory=list)
|
||||||
context: dict = field(default_factory=dict)
|
context: dict = field(default_factory=dict)
|
||||||
|
|
||||||
metadata: BlueprintMetadata | None = field(default=None)
|
metadata: BlueprintMetadata | None = field(default=None)
|
||||||
|
|
||||||
def iter_entries(self) -> Iterable[BlueprintEntry]:
|
|
||||||
if isinstance(self.entries, dict):
|
|
||||||
for _section, entries in self.entries.items():
|
|
||||||
yield from entries
|
|
||||||
else:
|
|
||||||
yield from self.entries
|
|
||||||
|
|
||||||
|
|
||||||
class YAMLTag:
|
class YAMLTag:
|
||||||
"""Base class for all YAML Tags"""
|
"""Base class for all YAML Tags"""
|
||||||
@ -234,7 +226,7 @@ class KeyOf(YAMLTag):
|
|||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
for _entry in blueprint.iter_entries():
|
for _entry in blueprint.entries:
|
||||||
if _entry.id == self.id_from and _entry._state.instance:
|
if _entry.id == self.id_from and _entry._state.instance:
|
||||||
# Special handling for PolicyBindingModels, as they'll have a different PK
|
# Special handling for PolicyBindingModels, as they'll have a different PK
|
||||||
# which is used when creating policy bindings
|
# which is used when creating policy bindings
|
||||||
@ -292,22 +284,6 @@ class Context(YAMLTag):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ParseJSON(YAMLTag):
|
|
||||||
"""Parse JSON from context/env/etc value"""
|
|
||||||
|
|
||||||
raw: str
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.raw = node.value
|
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
try:
|
|
||||||
return loads(self.raw)
|
|
||||||
except JSONDecodeError as exc:
|
|
||||||
raise EntryInvalidError.from_entry(exc, entry) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class Format(YAMLTag):
|
class Format(YAMLTag):
|
||||||
"""Format a string"""
|
"""Format a string"""
|
||||||
|
|
||||||
@ -683,7 +659,6 @@ class BlueprintLoader(SafeLoader):
|
|||||||
self.add_constructor("!Value", Value)
|
self.add_constructor("!Value", Value)
|
||||||
self.add_constructor("!Index", Index)
|
self.add_constructor("!Index", Index)
|
||||||
self.add_constructor("!AtIndex", AtIndex)
|
self.add_constructor("!AtIndex", AtIndex)
|
||||||
self.add_constructor("!ParseJSON", ParseJSON)
|
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
@ -57,6 +57,7 @@ from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
|||||||
EndpointDeviceConnection,
|
EndpointDeviceConnection,
|
||||||
)
|
)
|
||||||
from authentik.events.logs import LogEvent, capture_logs
|
from authentik.events.logs import LogEvent, capture_logs
|
||||||
|
from authentik.events.models import SystemTask
|
||||||
from authentik.events.utils import cleanse_dict
|
from authentik.events.utils import cleanse_dict
|
||||||
from authentik.flows.models import FlowToken, Stage
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
@ -76,7 +77,6 @@ from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
|
|||||||
from authentik.rbac.models import Role
|
from authentik.rbac.models import Role
|
||||||
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
|
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
|
||||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
|
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
|
||||||
from authentik.tasks.models import Task
|
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
# Context set when the serializer is created in a blueprint context
|
# Context set when the serializer is created in a blueprint context
|
||||||
@ -118,7 +118,7 @@ def excluded_models() -> list[type[Model]]:
|
|||||||
SCIMProviderGroup,
|
SCIMProviderGroup,
|
||||||
SCIMProviderUser,
|
SCIMProviderUser,
|
||||||
Tenant,
|
Tenant,
|
||||||
Task,
|
SystemTask,
|
||||||
ConnectionToken,
|
ConnectionToken,
|
||||||
AuthorizationCode,
|
AuthorizationCode,
|
||||||
AccessToken,
|
AccessToken,
|
||||||
@ -384,7 +384,7 @@ class Importer:
|
|||||||
def _apply_models(self, raise_errors=False) -> bool:
|
def _apply_models(self, raise_errors=False) -> bool:
|
||||||
"""Apply (create/update) models yaml"""
|
"""Apply (create/update) models yaml"""
|
||||||
self.__pk_map = {}
|
self.__pk_map = {}
|
||||||
for entry in self._import.iter_entries():
|
for entry in self._import.entries:
|
||||||
model_app_label, model_name = entry.get_model(self._import).split(".")
|
model_app_label, model_name = entry.get_model(self._import).split(".")
|
||||||
try:
|
try:
|
||||||
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
||||||
|
@ -44,7 +44,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
|||||||
return MetaResult()
|
return MetaResult()
|
||||||
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
||||||
|
|
||||||
apply_blueprint(self.blueprint_instance.pk)
|
apply_blueprint(str(self.blueprint_instance.pk))
|
||||||
return MetaResult()
|
return MetaResult()
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ class MetaModelRegistry:
|
|||||||
models = apps.get_models()
|
models = apps.get_models()
|
||||||
for _, value in self.models.items():
|
for _, value in self.models.items():
|
||||||
models.append(value)
|
models.append(value)
|
||||||
return sorted(models, key=str)
|
return models
|
||||||
|
|
||||||
def get_model(self, app_label: str, model_id: str) -> type[Model]:
|
def get_model(self, app_label: str, model_id: str) -> type[Model]:
|
||||||
"""Get model checks if any virtual models are registered, and falls back
|
"""Get model checks if any virtual models are registered, and falls back
|
||||||
|
@ -4,17 +4,12 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from hashlib import sha512
|
from hashlib import sha512
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import platform
|
from sys import platform
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from dacite.core import from_dict
|
from dacite.core import from_dict
|
||||||
from django.conf import settings
|
|
||||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_dramatiq_postgres.middleware import CurrentTask, CurrentTaskNotFound
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
from dramatiq.middleware import Middleware
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
from watchdog.events import (
|
from watchdog.events import (
|
||||||
FileCreatedEvent,
|
FileCreatedEvent,
|
||||||
@ -36,13 +31,15 @@ from authentik.blueprints.v1.importer import Importer
|
|||||||
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
|
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
|
||||||
from authentik.blueprints.v1.oci import OCI_PREFIX
|
from authentik.blueprints.v1.oci import OCI_PREFIX
|
||||||
from authentik.events.logs import capture_logs
|
from authentik.events.logs import capture_logs
|
||||||
|
from authentik.events.models import TaskStatus
|
||||||
|
from authentik.events.system_tasks import SystemTask, prefill_task
|
||||||
from authentik.events.utils import sanitize_dict
|
from authentik.events.utils import sanitize_dict
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.tasks.models import Task
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.tasks.schedules.models import Schedule
|
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
_file_watcher_started = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -56,9 +53,13 @@ class BlueprintFile:
|
|||||||
meta: BlueprintMetadata | None = field(default=None)
|
meta: BlueprintMetadata | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class BlueprintWatcherMiddleware(Middleware):
|
def start_blueprint_watcher():
|
||||||
def start_blueprint_watcher(self):
|
"""Start blueprint watcher, if it's not running already."""
|
||||||
"""Start blueprint watcher"""
|
# This function might be called twice since it's called on celery startup
|
||||||
|
|
||||||
|
global _file_watcher_started # noqa: PLW0603
|
||||||
|
if _file_watcher_started:
|
||||||
|
return
|
||||||
observer = Observer()
|
observer = Observer()
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if platform.startswith("linux"):
|
if platform.startswith("linux"):
|
||||||
@ -67,10 +68,7 @@ class BlueprintWatcherMiddleware(Middleware):
|
|||||||
BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True, **kwargs
|
BlueprintEventHandler(), CONFIG.get("blueprints_dir"), recursive=True, **kwargs
|
||||||
)
|
)
|
||||||
observer.start()
|
observer.start()
|
||||||
|
_file_watcher_started = True
|
||||||
def after_worker_boot(self, broker, worker):
|
|
||||||
if not settings.TEST:
|
|
||||||
self.start_blueprint_watcher()
|
|
||||||
|
|
||||||
|
|
||||||
class BlueprintEventHandler(FileSystemEventHandler):
|
class BlueprintEventHandler(FileSystemEventHandler):
|
||||||
@ -94,7 +92,7 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
|||||||
LOGGER.debug("new blueprint file created, starting discovery")
|
LOGGER.debug("new blueprint file created, starting discovery")
|
||||||
for tenant in Tenant.objects.filter(ready=True):
|
for tenant in Tenant.objects.filter(ready=True):
|
||||||
with tenant:
|
with tenant:
|
||||||
Schedule.dispatch_by_actor(blueprints_discovery)
|
blueprints_discovery.delay()
|
||||||
|
|
||||||
def on_modified(self, event: FileSystemEvent):
|
def on_modified(self, event: FileSystemEvent):
|
||||||
"""Process file modification"""
|
"""Process file modification"""
|
||||||
@ -105,14 +103,14 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
|||||||
with tenant:
|
with tenant:
|
||||||
for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True):
|
for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True):
|
||||||
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
|
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
|
||||||
apply_blueprint.send_with_options(args=(instance.pk,), rel_obj=instance)
|
apply_blueprint.delay(instance.pk.hex)
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@CELERY_APP.task(
|
||||||
description=_("Find blueprints as `blueprints_find` does, but return a safe dict."),
|
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
throws=(DatabaseError, ProgrammingError, InternalError),
|
||||||
)
|
)
|
||||||
def blueprints_find_dict():
|
def blueprints_find_dict():
|
||||||
|
"""Find blueprints as `blueprints_find` does, but return a safe dict"""
|
||||||
blueprints = []
|
blueprints = []
|
||||||
for blueprint in blueprints_find():
|
for blueprint in blueprints_find():
|
||||||
blueprints.append(sanitize_dict(asdict(blueprint)))
|
blueprints.append(sanitize_dict(asdict(blueprint)))
|
||||||
@ -148,19 +146,21 @@ def blueprints_find() -> list[BlueprintFile]:
|
|||||||
return blueprints
|
return blueprints
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@CELERY_APP.task(
|
||||||
description=_("Find blueprints and check if they need to be created in the database."),
|
throws=(DatabaseError, ProgrammingError, InternalError), base=SystemTask, bind=True
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
|
||||||
)
|
)
|
||||||
def blueprints_discovery(path: str | None = None):
|
@prefill_task
|
||||||
self: Task = CurrentTask.get_task()
|
def blueprints_discovery(self: SystemTask, path: str | None = None):
|
||||||
|
"""Find blueprints and check if they need to be created in the database"""
|
||||||
count = 0
|
count = 0
|
||||||
for blueprint in blueprints_find():
|
for blueprint in blueprints_find():
|
||||||
if path and blueprint.path != path:
|
if path and blueprint.path != path:
|
||||||
continue
|
continue
|
||||||
check_blueprint_v1_file(blueprint)
|
check_blueprint_v1_file(blueprint)
|
||||||
count += 1
|
count += 1
|
||||||
self.info(f"Successfully imported {count} files.")
|
self.set_status(
|
||||||
|
TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_blueprint_v1_file(blueprint: BlueprintFile):
|
def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||||
@ -187,26 +187,22 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
|||||||
)
|
)
|
||||||
if instance.last_applied_hash != blueprint.hash:
|
if instance.last_applied_hash != blueprint.hash:
|
||||||
LOGGER.info("Applying blueprint due to changed file", instance=instance, path=instance.path)
|
LOGGER.info("Applying blueprint due to changed file", instance=instance, path=instance.path)
|
||||||
apply_blueprint.send_with_options(args=(instance.pk,), rel_obj=instance)
|
apply_blueprint.delay(str(instance.pk))
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Apply single blueprint."))
|
@CELERY_APP.task(
|
||||||
def apply_blueprint(instance_pk: UUID):
|
bind=True,
|
||||||
try:
|
base=SystemTask,
|
||||||
self: Task = CurrentTask.get_task()
|
)
|
||||||
except CurrentTaskNotFound:
|
def apply_blueprint(self: SystemTask, instance_pk: str):
|
||||||
self = Task()
|
"""Apply single blueprint"""
|
||||||
self.set_uid(str(instance_pk))
|
self.save_on_success = False
|
||||||
instance: BlueprintInstance | None = None
|
instance: BlueprintInstance | None = None
|
||||||
try:
|
try:
|
||||||
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
|
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
|
||||||
if not instance:
|
if not instance or not instance.enabled:
|
||||||
self.warning(f"Could not find blueprint {instance_pk}, skipping")
|
|
||||||
return
|
return
|
||||||
self.set_uid(slugify(instance.name))
|
self.set_uid(slugify(instance.name))
|
||||||
if not instance.enabled:
|
|
||||||
self.info(f"Blueprint {instance.name} is disabled, skipping")
|
|
||||||
return
|
|
||||||
blueprint_content = instance.retrieve()
|
blueprint_content = instance.retrieve()
|
||||||
file_hash = sha512(blueprint_content.encode()).hexdigest()
|
file_hash = sha512(blueprint_content.encode()).hexdigest()
|
||||||
importer = Importer.from_string(blueprint_content, instance.context)
|
importer = Importer.from_string(blueprint_content, instance.context)
|
||||||
@ -216,18 +212,19 @@ def apply_blueprint(instance_pk: UUID):
|
|||||||
if not valid:
|
if not valid:
|
||||||
instance.status = BlueprintInstanceStatus.ERROR
|
instance.status = BlueprintInstanceStatus.ERROR
|
||||||
instance.save()
|
instance.save()
|
||||||
self.logs(logs)
|
self.set_status(TaskStatus.ERROR, *logs)
|
||||||
return
|
return
|
||||||
with capture_logs() as logs:
|
with capture_logs() as logs:
|
||||||
applied = importer.apply()
|
applied = importer.apply()
|
||||||
if not applied:
|
if not applied:
|
||||||
instance.status = BlueprintInstanceStatus.ERROR
|
instance.status = BlueprintInstanceStatus.ERROR
|
||||||
instance.save()
|
instance.save()
|
||||||
self.logs(logs)
|
self.set_status(TaskStatus.ERROR, *logs)
|
||||||
return
|
return
|
||||||
instance.status = BlueprintInstanceStatus.SUCCESSFUL
|
instance.status = BlueprintInstanceStatus.SUCCESSFUL
|
||||||
instance.last_applied_hash = file_hash
|
instance.last_applied_hash = file_hash
|
||||||
instance.last_applied = now()
|
instance.last_applied = now()
|
||||||
|
self.set_status(TaskStatus.SUCCESSFUL)
|
||||||
except (
|
except (
|
||||||
OSError,
|
OSError,
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
@ -238,14 +235,15 @@ def apply_blueprint(instance_pk: UUID):
|
|||||||
) as exc:
|
) as exc:
|
||||||
if instance:
|
if instance:
|
||||||
instance.status = BlueprintInstanceStatus.ERROR
|
instance.status = BlueprintInstanceStatus.ERROR
|
||||||
self.error(exc)
|
self.set_error(exc)
|
||||||
finally:
|
finally:
|
||||||
if instance:
|
if instance:
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Remove blueprints which couldn't be fetched."))
|
@CELERY_APP.task()
|
||||||
def clear_failed_blueprints():
|
def clear_failed_blueprints():
|
||||||
|
"""Remove blueprints which couldn't be fetched"""
|
||||||
# Exclude OCI blueprints as those might be temporarily unavailable
|
# Exclude OCI blueprints as those might be temporarily unavailable
|
||||||
for blueprint in BlueprintInstance.objects.exclude(path__startswith=OCI_PREFIX):
|
for blueprint in BlueprintInstance.objects.exclude(path__startswith=OCI_PREFIX):
|
||||||
try:
|
try:
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
"""authentik brands app"""
|
"""authentik brands app"""
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AuthentikBrandsConfig(ManagedAppConfig):
|
class AuthentikBrandsConfig(AppConfig):
|
||||||
"""authentik Brand app"""
|
"""authentik Brand app"""
|
||||||
|
|
||||||
name = "authentik.brands"
|
name = "authentik.brands"
|
||||||
label = "authentik_brands"
|
label = "authentik_brands"
|
||||||
verbose_name = "authentik Brands"
|
verbose_name = "authentik Brands"
|
||||||
default = True
|
|
||||||
mountpoints = {
|
mountpoints = {
|
||||||
"authentik.brands.urls_root": "",
|
"authentik.brands.urls_root": "",
|
||||||
}
|
}
|
||||||
default = True
|
|
||||||
|
@ -148,14 +148,3 @@ class TestBrands(APITestCase):
|
|||||||
"default_locale": "",
|
"default_locale": "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_custom_css(self):
|
|
||||||
"""Test custom_css"""
|
|
||||||
brand = create_test_brand()
|
|
||||||
brand.branding_custom_css = """* {
|
|
||||||
font-family: "Foo bar";
|
|
||||||
}"""
|
|
||||||
brand.save()
|
|
||||||
res = self.client.get(reverse("authentik_core:if-user"))
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
self.assertIn(brand.branding_custom_css, res.content.decode())
|
|
||||||
|
@ -5,8 +5,6 @@ from typing import Any
|
|||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.db.models import Value as V
|
from django.db.models import Value as V
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from django.utils.html import _json_script_escapes
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from authentik import get_full_version
|
from authentik import get_full_version
|
||||||
from authentik.brands.models import Brand
|
from authentik.brands.models import Brand
|
||||||
@ -34,13 +32,8 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
|||||||
"""Context Processor that injects brand object into every template"""
|
"""Context Processor that injects brand object into every template"""
|
||||||
brand = getattr(request, "brand", DEFAULT_BRAND)
|
brand = getattr(request, "brand", DEFAULT_BRAND)
|
||||||
tenant = getattr(request, "tenant", Tenant())
|
tenant = getattr(request, "tenant", Tenant())
|
||||||
# similarly to `json_script` we escape everything HTML-related, however django
|
|
||||||
# only directly exposes this as a function that also wraps it in a <script> tag
|
|
||||||
# which we dont want for CSS
|
|
||||||
brand_css = mark_safe(str(brand.branding_custom_css).translate(_json_script_escapes)) # nosec
|
|
||||||
return {
|
return {
|
||||||
"brand": brand,
|
"brand": brand,
|
||||||
"brand_css": brand_css,
|
|
||||||
"footer_links": tenant.footer_links,
|
"footer_links": tenant.footer_links,
|
||||||
"html_meta": {**get_http_meta()},
|
"html_meta": {**get_http_meta()},
|
||||||
"version": get_full_version(),
|
"version": get_full_version(),
|
||||||
|
@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
@ -18,6 +20,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.api.pagination import Pagination
|
from authentik.api.pagination import Pagination
|
||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.api.providers import ProviderSerializer
|
||||||
@ -25,6 +28,7 @@ from authentik.core.api.used_by import UsedByMixin
|
|||||||
from authentik.core.api.utils import ModelSerializer
|
from authentik.core.api.utils import ModelSerializer
|
||||||
from authentik.core.models import Application, User
|
from authentik.core.models import Application, User
|
||||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||||
|
from authentik.events.models import EventAction
|
||||||
from authentik.lib.utils.file import (
|
from authentik.lib.utils.file import (
|
||||||
FilePathSerializer,
|
FilePathSerializer,
|
||||||
FileUploadSerializer,
|
FileUploadSerializer,
|
||||||
@ -317,3 +321,18 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"""Set application icon (as URL)"""
|
"""Set application icon (as URL)"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
return set_file_url(request, app, "meta_icon")
|
return set_file_url(request, app, "meta_icon")
|
||||||
|
|
||||||
|
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||||
|
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||||
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
def metrics(self, request: Request, slug: str):
|
||||||
|
"""Metrics for application logins"""
|
||||||
|
app = self.get_object()
|
||||||
|
return Response(
|
||||||
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
|
context__authorized_application__pk=app.pk.hex,
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
"""Authenticator Devices API Views"""
|
"""Authenticator Devices API Views"""
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.fields import (
|
from rest_framework.fields import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
@ -13,7 +15,6 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
|
|
||||||
from authentik.core.api.users import ParamUserSerializer
|
|
||||||
from authentik.core.api.utils import MetaNameSerializer
|
from authentik.core.api.utils import MetaNameSerializer
|
||||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
||||||
from authentik.stages.authenticator import device_classes, devices_for_user
|
from authentik.stages.authenticator import device_classes, devices_for_user
|
||||||
@ -22,7 +23,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
|||||||
|
|
||||||
|
|
||||||
class DeviceSerializer(MetaNameSerializer):
|
class DeviceSerializer(MetaNameSerializer):
|
||||||
"""Serializer for authenticator devices"""
|
"""Serializer for Duo authenticator devices"""
|
||||||
|
|
||||||
pk = CharField()
|
pk = CharField()
|
||||||
name = CharField()
|
name = CharField()
|
||||||
@ -32,27 +33,22 @@ class DeviceSerializer(MetaNameSerializer):
|
|||||||
last_updated = DateTimeField(read_only=True)
|
last_updated = DateTimeField(read_only=True)
|
||||||
last_used = DateTimeField(read_only=True, allow_null=True)
|
last_used = DateTimeField(read_only=True, allow_null=True)
|
||||||
extra_description = SerializerMethodField()
|
extra_description = SerializerMethodField()
|
||||||
external_id = SerializerMethodField()
|
|
||||||
|
|
||||||
def get_type(self, instance: Device) -> str:
|
def get_type(self, instance: Device) -> str:
|
||||||
"""Get type of device"""
|
"""Get type of device"""
|
||||||
return instance._meta.label
|
return instance._meta.label
|
||||||
|
|
||||||
def get_extra_description(self, instance: Device) -> str | None:
|
def get_extra_description(self, instance: Device) -> str:
|
||||||
"""Get extra description"""
|
"""Get extra description"""
|
||||||
if isinstance(instance, WebAuthnDevice):
|
if isinstance(instance, WebAuthnDevice):
|
||||||
return instance.device_type.description if instance.device_type else None
|
return (
|
||||||
|
instance.device_type.description
|
||||||
|
if instance.device_type
|
||||||
|
else _("Extra description not available")
|
||||||
|
)
|
||||||
if isinstance(instance, EndpointDevice):
|
if isinstance(instance, EndpointDevice):
|
||||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
||||||
return None
|
return ""
|
||||||
|
|
||||||
def get_external_id(self, instance: Device) -> str | None:
|
|
||||||
"""Get external Device ID"""
|
|
||||||
if isinstance(instance, WebAuthnDevice):
|
|
||||||
return instance.device_type.aaguid if instance.device_type else None
|
|
||||||
if isinstance(instance, EndpointDevice):
|
|
||||||
return instance.data.get("deviceSignals", {}).get("deviceModel")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceViewSet(ViewSet):
|
class DeviceViewSet(ViewSet):
|
||||||
@ -61,6 +57,7 @@ class DeviceViewSet(ViewSet):
|
|||||||
serializer_class = DeviceSerializer
|
serializer_class = DeviceSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses={200: DeviceSerializer(many=True)})
|
||||||
def list(self, request: Request) -> Response:
|
def list(self, request: Request) -> Response:
|
||||||
"""Get all devices for current user"""
|
"""Get all devices for current user"""
|
||||||
devices = devices_for_user(request.user)
|
devices = devices_for_user(request.user)
|
||||||
@ -82,11 +79,18 @@ class AdminDeviceViewSet(ViewSet):
|
|||||||
yield from device_set
|
yield from device_set
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[ParamUserSerializer],
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="user",
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
)
|
||||||
|
],
|
||||||
responses={200: DeviceSerializer(many=True)},
|
responses={200: DeviceSerializer(many=True)},
|
||||||
)
|
)
|
||||||
def list(self, request: Request) -> Response:
|
def list(self, request: Request) -> Response:
|
||||||
"""Get all devices for current user"""
|
"""Get all devices for current user"""
|
||||||
args = ParamUserSerializer(data=request.query_params)
|
kwargs = {}
|
||||||
args.is_valid(raise_exception=True)
|
if "user" in request.query_params:
|
||||||
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)
|
kwargs = {"user": request.query_params["user"]}
|
||||||
|
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)
|
||||||
|
@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
@ -51,6 +52,7 @@ from rest_framework.validators import UniqueValidator
|
|||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||||
from authentik.brands.models import Brand
|
from authentik.brands.models import Brand
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
@ -82,7 +84,6 @@ from authentik.flows.views.executor import QS_KEY_TOKEN
|
|||||||
from authentik.lib.avatars import get_avatar
|
from authentik.lib.avatars import get_avatar
|
||||||
from authentik.rbac.decorators import permission_required
|
from authentik.rbac.decorators import permission_required
|
||||||
from authentik.rbac.models import 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.models import EmailStage
|
||||||
from authentik.stages.email.tasks import send_mails
|
from authentik.stages.email.tasks import send_mails
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
@ -90,12 +91,6 @@ from authentik.stages.email.utils import TemplateEmailMessage
|
|||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class ParamUserSerializer(PassiveSerializer):
|
|
||||||
"""Partial serializer for query parameters to select a user"""
|
|
||||||
|
|
||||||
user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class UserGroupSerializer(ModelSerializer):
|
class UserGroupSerializer(ModelSerializer):
|
||||||
"""Simplified Group Serializer for user's groups"""
|
"""Simplified Group Serializer for user's groups"""
|
||||||
|
|
||||||
@ -321,6 +316,53 @@ class SessionUserSerializer(PassiveSerializer):
|
|||||||
original = UserSelfSerializer(required=False)
|
original = UserSelfSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserMetricsSerializer(PassiveSerializer):
|
||||||
|
"""User Metrics"""
|
||||||
|
|
||||||
|
logins = SerializerMethodField()
|
||||||
|
logins_failed = SerializerMethodField()
|
||||||
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_logins(self, _):
|
||||||
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
request = self.context["request"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.LOGIN, user__pk=user.pk
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_logins_failed(self, _):
|
||||||
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
request = self.context["request"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.LOGIN_FAILED, context__username=user.username
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
|
def get_authorizations(self, _):
|
||||||
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
|
user = self.context["user"]
|
||||||
|
request = self.context["request"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
|
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
||||||
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UsersFilter(FilterSet):
|
class UsersFilter(FilterSet):
|
||||||
"""Filter for users"""
|
"""Filter for users"""
|
||||||
|
|
||||||
@ -392,23 +434,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = User.objects.none()
|
queryset = User.objects.none()
|
||||||
ordering = ["username"]
|
ordering = ["username"]
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
filterset_class = UsersFilter
|
|
||||||
search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
|
search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
|
||||||
|
filterset_class = UsersFilter
|
||||||
def get_ql_fields(self):
|
|
||||||
from djangoql.schema import BoolField, StrField
|
|
||||||
|
|
||||||
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
|
|
||||||
|
|
||||||
return [
|
|
||||||
StrField(User, "username"),
|
|
||||||
StrField(User, "name"),
|
|
||||||
StrField(User, "email"),
|
|
||||||
StrField(User, "path"),
|
|
||||||
BoolField(User, "is_active", nullable=True),
|
|
||||||
ChoiceSearchField(User, "type"),
|
|
||||||
JSONSearchField(User, "attributes", suggest_nested=False),
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
base_qs = User.objects.all().exclude_anonymous()
|
base_qs = User.objects.all().exclude_anonymous()
|
||||||
@ -424,7 +451,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
return super().list(request, *args, **kwargs)
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
def _create_recovery_link(self, for_email=False) -> tuple[str, Token]:
|
def _create_recovery_link(self) -> tuple[str, Token]:
|
||||||
"""Create a recovery link (when the current brand has a recovery flow set),
|
"""Create a recovery link (when the current brand has a recovery flow set),
|
||||||
that can either be shown to an admin or sent to the user directly"""
|
that can either be shown to an admin or sent to the user directly"""
|
||||||
brand: Brand = self.request._request.brand
|
brand: Brand = self.request._request.brand
|
||||||
@ -446,16 +473,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{"non_field_errors": "Recovery flow not applicable to user"}
|
{"non_field_errors": "Recovery flow not applicable to user"}
|
||||||
) from None
|
) from None
|
||||||
_plan = FlowToken.pickle(plan)
|
|
||||||
if for_email:
|
|
||||||
_plan = pickle_flow_token_for_email(plan)
|
|
||||||
token, __ = FlowToken.objects.update_or_create(
|
token, __ = FlowToken.objects.update_or_create(
|
||||||
identifier=f"{user.uid}-password-reset",
|
identifier=f"{user.uid}-password-reset",
|
||||||
defaults={
|
defaults={
|
||||||
"user": user,
|
"user": user,
|
||||||
"flow": flow,
|
"flow": flow,
|
||||||
"_plan": _plan,
|
"_plan": FlowToken.pickle(plan),
|
||||||
"revoke_on_execution": not for_email,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
||||||
@ -579,6 +602,17 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
update_session_auth_hash(self.request, user)
|
update_session_auth_hash(self.request, user)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||||
|
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||||
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
def metrics(self, request: Request, pk: int) -> Response:
|
||||||
|
"""User metrics per 1h"""
|
||||||
|
user: User = self.get_object()
|
||||||
|
serializer = UserMetricsSerializer(instance={})
|
||||||
|
serializer.context["user"] = user
|
||||||
|
serializer.context["request"] = request
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
@permission_required("authentik_core.reset_user_password")
|
@permission_required("authentik_core.reset_user_password")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses={
|
responses={
|
||||||
@ -614,7 +648,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
if for_user.email == "":
|
if for_user.email == "":
|
||||||
LOGGER.debug("User doesn't have an email address")
|
LOGGER.debug("User doesn't have an email address")
|
||||||
raise ValidationError({"non_field_errors": "User does not have an email address set."})
|
raise ValidationError({"non_field_errors": "User does not have an email address set."})
|
||||||
link, token = self._create_recovery_link(for_email=True)
|
link, token = self._create_recovery_link()
|
||||||
# Lookup the email stage to assure the current user can access it
|
# Lookup the email stage to assure the current user can access it
|
||||||
stages = get_objects_for_user(
|
stages = get_objects_for_user(
|
||||||
request.user, "authentik_stages_email.view_emailstage"
|
request.user, "authentik_stages_email.view_emailstage"
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||||
from drf_spectacular.plumbing import build_basic_type
|
from drf_spectacular.plumbing import build_basic_type
|
||||||
@ -31,27 +30,7 @@ def is_dict(value: Any):
|
|||||||
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
|
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
|
||||||
|
|
||||||
|
|
||||||
class JSONDictField(JSONField):
|
|
||||||
"""JSON Field which only allows dictionaries"""
|
|
||||||
|
|
||||||
default_validators = [is_dict]
|
|
||||||
|
|
||||||
|
|
||||||
class JSONExtension(OpenApiSerializerFieldExtension):
|
|
||||||
"""Generate API Schema for JSON fields as"""
|
|
||||||
|
|
||||||
target_class = "authentik.core.api.utils.JSONDictField"
|
|
||||||
|
|
||||||
def map_serializer_field(self, auto_schema, direction):
|
|
||||||
return build_basic_type(OpenApiTypes.OBJECT)
|
|
||||||
|
|
||||||
|
|
||||||
class ModelSerializer(BaseModelSerializer):
|
class ModelSerializer(BaseModelSerializer):
|
||||||
|
|
||||||
# By default, JSON fields we have are used to store dictionaries
|
|
||||||
serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy()
|
|
||||||
serializer_field_mapping[models.JSONField] = JSONDictField
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
instance = super().create(validated_data)
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
@ -92,6 +71,21 @@ class ModelSerializer(BaseModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class JSONDictField(JSONField):
|
||||||
|
"""JSON Field which only allows dictionaries"""
|
||||||
|
|
||||||
|
default_validators = [is_dict]
|
||||||
|
|
||||||
|
|
||||||
|
class JSONExtension(OpenApiSerializerFieldExtension):
|
||||||
|
"""Generate API Schema for JSON fields as"""
|
||||||
|
|
||||||
|
target_class = "authentik.core.api.utils.JSONDictField"
|
||||||
|
|
||||||
|
def map_serializer_field(self, auto_schema, direction):
|
||||||
|
return build_basic_type(OpenApiTypes.OBJECT)
|
||||||
|
|
||||||
|
|
||||||
class PassiveSerializer(Serializer):
|
class PassiveSerializer(Serializer):
|
||||||
"""Base serializer class which doesn't implement create/update methods"""
|
"""Base serializer class which doesn't implement create/update methods"""
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
"""authentik core app config"""
|
"""authentik core app config"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikCoreConfig(ManagedAppConfig):
|
class AuthentikCoreConfig(ManagedAppConfig):
|
||||||
@ -13,6 +14,14 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
|||||||
mountpoint = ""
|
mountpoint = ""
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
|
@ManagedAppConfig.reconcile_global
|
||||||
|
def debug_worker_hook(self):
|
||||||
|
"""Dispatch startup tasks inline when debugging"""
|
||||||
|
if settings.DEBUG:
|
||||||
|
from authentik.root.celery import worker_ready_hook
|
||||||
|
|
||||||
|
worker_ready_hook()
|
||||||
|
|
||||||
@ManagedAppConfig.reconcile_tenant
|
@ManagedAppConfig.reconcile_tenant
|
||||||
def source_inbuilt(self):
|
def source_inbuilt(self):
|
||||||
"""Reconcile inbuilt source"""
|
"""Reconcile inbuilt source"""
|
||||||
@ -25,18 +34,3 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
|||||||
},
|
},
|
||||||
managed=Source.MANAGED_INBUILT,
|
managed=Source.MANAGED_INBUILT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.core.tasks import clean_expired_models, clean_temporary_users
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=clean_expired_models,
|
|
||||||
crontab="2-59/5 * * * *",
|
|
||||||
),
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=clean_temporary_users,
|
|
||||||
crontab="9-59/5 * * * *",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
21
authentik/core/management/commands/bootstrap_tasks.py
Normal file
21
authentik/core/management/commands/bootstrap_tasks.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""Run bootstrap tasks"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django_tenants.utils import get_public_schema_name
|
||||||
|
|
||||||
|
from authentik.root.celery import _get_startup_tasks_all_tenants, _get_startup_tasks_default_tenant
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Run bootstrap tasks to ensure certain objects are created"""
|
||||||
|
|
||||||
|
def handle(self, **options):
|
||||||
|
for task in _get_startup_tasks_default_tenant():
|
||||||
|
with Tenant.objects.get(schema_name=get_public_schema_name()):
|
||||||
|
task()
|
||||||
|
|
||||||
|
for task in _get_startup_tasks_all_tenants():
|
||||||
|
for tenant in Tenant.objects.filter(ready=True):
|
||||||
|
with tenant:
|
||||||
|
task()
|
@ -13,6 +13,7 @@ class Command(TenantCommand):
|
|||||||
parser.add_argument("usernames", nargs="*", type=str)
|
parser.add_argument("usernames", nargs="*", type=str)
|
||||||
|
|
||||||
def handle_per_tenant(self, **options):
|
def handle_per_tenant(self, **options):
|
||||||
|
print(options)
|
||||||
new_type = UserTypes(options["type"])
|
new_type = UserTypes(options["type"])
|
||||||
qs = (
|
qs = (
|
||||||
User.objects.exclude_anonymous()
|
User.objects.exclude_anonymous()
|
||||||
|
47
authentik/core/management/commands/worker.py
Normal file
47
authentik/core/management/commands/worker.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""Run worker"""
|
||||||
|
|
||||||
|
from sys import exit as sysexit
|
||||||
|
from tempfile import tempdir
|
||||||
|
|
||||||
|
from celery.apps.worker import Worker
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import close_old_connections
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.lib.debug import start_debug_server
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Run worker"""
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"-b",
|
||||||
|
"--beat",
|
||||||
|
action="store_false",
|
||||||
|
help="When set, this worker will _not_ run Beat (scheduled) tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, **options):
|
||||||
|
LOGGER.debug("Celery options", **options)
|
||||||
|
close_old_connections()
|
||||||
|
start_debug_server()
|
||||||
|
worker: Worker = CELERY_APP.Worker(
|
||||||
|
no_color=False,
|
||||||
|
quiet=True,
|
||||||
|
optimization="fair",
|
||||||
|
autoscale=(CONFIG.get_int("worker.concurrency"), 1),
|
||||||
|
task_events=True,
|
||||||
|
beat=options.get("beat", True),
|
||||||
|
schedule_filename=f"{tempdir}/celerybeat-schedule",
|
||||||
|
queues=["authentik", "authentik_scheduled", "authentik_events"],
|
||||||
|
)
|
||||||
|
for task in CELERY_APP.tasks:
|
||||||
|
LOGGER.debug("Registered task", task=task)
|
||||||
|
|
||||||
|
worker.start()
|
||||||
|
sysexit(worker.exitcode)
|
@ -79,7 +79,6 @@ def _migrate_session(
|
|||||||
AuthenticatedSession.objects.using(db_alias).create(
|
AuthenticatedSession.objects.using(db_alias).create(
|
||||||
session=session,
|
session=session,
|
||||||
user=old_auth_session.user,
|
user=old_auth_session.user,
|
||||||
uuid=old_auth_session.uuid,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,81 +1,10 @@
|
|||||||
# Generated by Django 5.1.9 on 2025-05-14 11:15
|
# Generated by Django 5.1.9 on 2025-05-14 11:15
|
||||||
|
|
||||||
from django.apps.registry import Apps, apps as global_apps
|
from django.apps.registry import Apps
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
from django.contrib.contenttypes.management import create_contenttypes
|
|
||||||
from django.contrib.auth.management import create_permissions
|
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
def migrate_authenticated_session_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
"""Migrate permissions from OldAuthenticatedSession to AuthenticatedSession"""
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
|
|
||||||
# `apps` here is just an instance of `django.db.migrations.state.AppConfigStub`, we need the
|
|
||||||
# real config for creating permissions and content types
|
|
||||||
authentik_core_config = global_apps.get_app_config("authentik_core")
|
|
||||||
# These are only ran by django after all migrations, but we need them right now.
|
|
||||||
# `global_apps` is needed,
|
|
||||||
create_permissions(authentik_core_config, using=db_alias, verbosity=1)
|
|
||||||
create_contenttypes(authentik_core_config, using=db_alias, verbosity=1)
|
|
||||||
|
|
||||||
# But from now on, this is just a regular migration, so use `apps`
|
|
||||||
Permission = apps.get_model("auth", "Permission")
|
|
||||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
|
||||||
|
|
||||||
try:
|
|
||||||
old_ct = ContentType.objects.using(db_alias).get(
|
|
||||||
app_label="authentik_core", model="oldauthenticatedsession"
|
|
||||||
)
|
|
||||||
new_ct = ContentType.objects.using(db_alias).get(
|
|
||||||
app_label="authentik_core", model="authenticatedsession"
|
|
||||||
)
|
|
||||||
except ContentType.DoesNotExist:
|
|
||||||
# This should exist at this point, but if not, let's cut our losses
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get all permissions for the old content type
|
|
||||||
old_perms = Permission.objects.using(db_alias).filter(content_type=old_ct)
|
|
||||||
|
|
||||||
# Create equivalent permissions for the new content type
|
|
||||||
for old_perm in old_perms:
|
|
||||||
new_perm = (
|
|
||||||
Permission.objects.using(db_alias)
|
|
||||||
.filter(
|
|
||||||
content_type=new_ct,
|
|
||||||
codename=old_perm.codename,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not new_perm:
|
|
||||||
# This should exist at this point, but if not, let's cut our losses
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Global user permissions
|
|
||||||
User = apps.get_model("authentik_core", "User")
|
|
||||||
User.user_permissions.through.objects.using(db_alias).filter(
|
|
||||||
permission=old_perm
|
|
||||||
).all().update(permission=new_perm)
|
|
||||||
|
|
||||||
# Global role permissions
|
|
||||||
DjangoGroup = apps.get_model("auth", "Group")
|
|
||||||
DjangoGroup.permissions.through.objects.using(db_alias).filter(
|
|
||||||
permission=old_perm
|
|
||||||
).all().update(permission=new_perm)
|
|
||||||
|
|
||||||
# Object user permissions
|
|
||||||
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
|
|
||||||
UserObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
|
|
||||||
permission=new_perm, content_type=new_ct
|
|
||||||
)
|
|
||||||
|
|
||||||
# Object role permissions
|
|
||||||
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
|
|
||||||
GroupObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
|
|
||||||
permission=new_perm, content_type=new_ct
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_old_authenticated_session_content_type(
|
def remove_old_authenticated_session_content_type(
|
||||||
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||||
):
|
):
|
||||||
@ -92,12 +21,7 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(
|
|
||||||
code=migrate_authenticated_session_permissions,
|
|
||||||
reverse_code=migrations.RunPython.noop,
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
migrations.RunPython(
|
||||||
code=remove_old_authenticated_session_content_type,
|
code=remove_old_authenticated_session_content_type,
|
||||||
reverse_code=migrations.RunPython.noop,
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -18,7 +18,7 @@ from django.http import HttpRequest
|
|||||||
from django.utils.functional import SimpleLazyObject, cached_property
|
from django.utils.functional import SimpleLazyObject, cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_cte import CTE, with_cte
|
from django_cte import CTEQuerySet, With
|
||||||
from guardian.conf import settings
|
from guardian.conf import settings
|
||||||
from guardian.mixins import GuardianUserMixin
|
from guardian.mixins import GuardianUserMixin
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
@ -136,7 +136,7 @@ class AttributesMixin(models.Model):
|
|||||||
return instance, False
|
return instance, False
|
||||||
|
|
||||||
|
|
||||||
class GroupQuerySet(QuerySet):
|
class GroupQuerySet(CTEQuerySet):
|
||||||
def with_children_recursive(self):
|
def with_children_recursive(self):
|
||||||
"""Recursively get all groups that have the current queryset as parents
|
"""Recursively get all groups that have the current queryset as parents
|
||||||
or are indirectly related."""
|
or are indirectly related."""
|
||||||
@ -165,9 +165,9 @@ class GroupQuerySet(QuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Build the recursive query, see above
|
# Build the recursive query, see above
|
||||||
cte = CTE.recursive(make_cte)
|
cte = With.recursive(make_cte)
|
||||||
# Return the result, as a usable queryset for Group.
|
# Return the result, as a usable queryset for Group.
|
||||||
return with_cte(cte, select=cte.join(Group, group_uuid=cte.col.group_uuid))
|
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
|
||||||
|
|
||||||
|
|
||||||
class Group(SerializerModel, AttributesMixin):
|
class Group(SerializerModel, AttributesMixin):
|
||||||
@ -1082,12 +1082,6 @@ class AuthenticatedSession(SerializerModel):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[Serializer]:
|
|
||||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionSerializer
|
|
||||||
|
|
||||||
return AuthenticatedSessionSerializer
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Authenticated Session")
|
verbose_name = _("Authenticated Session")
|
||||||
verbose_name_plural = _("Authenticated Sessions")
|
verbose_name_plural = _("Authenticated Sessions")
|
||||||
|
@ -3,9 +3,6 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django_dramatiq_postgres.middleware import CurrentTask
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
@ -14,14 +11,17 @@ from authentik.core.models import (
|
|||||||
ExpiringModel,
|
ExpiringModel,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.tasks.models import Task
|
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Remove expired objects."))
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
def clean_expired_models():
|
@prefill_task
|
||||||
self: Task = CurrentTask.get_task()
|
def clean_expired_models(self: SystemTask):
|
||||||
|
"""Remove expired objects"""
|
||||||
|
messages = []
|
||||||
for cls in ExpiringModel.__subclasses__():
|
for cls in ExpiringModel.__subclasses__():
|
||||||
cls: ExpiringModel
|
cls: ExpiringModel
|
||||||
objects = (
|
objects = (
|
||||||
@ -31,13 +31,16 @@ def clean_expired_models():
|
|||||||
for obj in objects:
|
for obj in objects:
|
||||||
obj.expire_action()
|
obj.expire_action()
|
||||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||||
|
self.set_status(TaskStatus.SUCCESSFUL, *messages)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
def clean_temporary_users():
|
@prefill_task
|
||||||
self: Task = CurrentTask.get_task()
|
def clean_temporary_users(self: SystemTask):
|
||||||
|
"""Remove temporary users created by SAML Sources"""
|
||||||
_now = datetime.now()
|
_now = datetime.now()
|
||||||
|
messages = []
|
||||||
deleted_users = 0
|
deleted_users = 0
|
||||||
for user in User.objects.filter(**{f"attributes__{USER_ATTRIBUTE_GENERATED}": True}):
|
for user in User.objects.filter(**{f"attributes__{USER_ATTRIBUTE_GENERATED}": True}):
|
||||||
if not user.attributes.get(USER_ATTRIBUTE_EXPIRES):
|
if not user.attributes.get(USER_ATTRIBUTE_EXPIRES):
|
||||||
@ -49,4 +52,5 @@ def clean_temporary_users():
|
|||||||
LOGGER.debug("User is expired and will be deleted.", user=user, delta=delta)
|
LOGGER.debug("User is expired and will be deleted.", user=user, delta=delta)
|
||||||
user.delete()
|
user.delete()
|
||||||
deleted_users += 1
|
deleted_users += 1
|
||||||
self.info(f"Successfully deleted {deleted_users} users.")
|
messages.append(f"Successfully deleted {deleted_users} users.")
|
||||||
|
self.set_status(TaskStatus.SUCCESSFUL, *messages)
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
<style>{{ brand_css }}</style>
|
<style>{{ brand.branding_custom_css }}</style>
|
||||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container alignment="bottom"></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<ak-interface-admin>
|
<ak-interface-admin>
|
||||||
<ak-loading></ak-loading>
|
<ak-loading></ak-loading>
|
||||||
</ak-interface-admin>
|
</ak-interface-admin>
|
||||||
|
@ -114,7 +114,6 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
response.content.decode(),
|
response.content.decode(),
|
||||||
{
|
{
|
||||||
"autocomplete": {},
|
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": 0,
|
"next": 0,
|
||||||
"previous": 0,
|
"previous": 0,
|
||||||
@ -168,7 +167,6 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
response.content.decode(),
|
response.content.decode(),
|
||||||
{
|
{
|
||||||
"autocomplete": {},
|
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": 0,
|
"next": 0,
|
||||||
"previous": 0,
|
"previous": 0,
|
||||||
|
@ -36,7 +36,7 @@ class TestTasks(APITestCase):
|
|||||||
expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
|
expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
|
||||||
)
|
)
|
||||||
key = token.key
|
key = token.key
|
||||||
clean_expired_models.send()
|
clean_expired_models.delay().get()
|
||||||
token.refresh_from_db()
|
token.refresh_from_db()
|
||||||
self.assertNotEqual(key, token.key)
|
self.assertNotEqual(key, token.key)
|
||||||
|
|
||||||
@ -50,5 +50,5 @@ class TestTasks(APITestCase):
|
|||||||
USER_ATTRIBUTE_EXPIRES: mktime(now().timetuple()),
|
USER_ATTRIBUTE_EXPIRES: mktime(now().timetuple()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
clean_temporary_users.send()
|
clean_temporary_users.delay().get()
|
||||||
self.assertFalse(User.objects.filter(username=username))
|
self.assertFalse(User.objects.filter(username=username))
|
||||||
|
@ -81,6 +81,22 @@ class TestUsersAPI(APITestCase):
|
|||||||
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
|
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_metrics(self):
|
||||||
|
"""Test user's metrics"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_metrics_denied(self):
|
||||||
|
"""Test user's metrics (non-superuser)"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_recovery_no_flow(self):
|
def test_recovery_no_flow(self):
|
||||||
"""Test user recovery link (no recovery flow set)"""
|
"""Test user recovery link (no recovery flow set)"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
@ -4,8 +4,6 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.lib.utils.time import fqdn_rand
|
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
|
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
|
||||||
|
|
||||||
@ -69,14 +67,3 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
"key_data": builder.private_key,
|
"key_data": builder.private_key,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.crypto.tasks import certificate_discovery
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=certificate_discovery,
|
|
||||||
crontab=f"{fqdn_rand('crypto_certificate_discovery')} * * * *",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
13
authentik/crypto/settings.py
Normal file
13
authentik/crypto/settings.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Crypto task Settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"crypto_certificate_discovery": {
|
||||||
|
"task": "authentik.crypto.tasks.certificate_discovery",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("crypto_certificate_discovery"), hour="*"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
@ -7,13 +7,13 @@ from cryptography.hazmat.backends import default_backend
|
|||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
from cryptography.x509.base import load_pem_x509_certificate
|
from cryptography.x509.base import load_pem_x509_certificate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_dramatiq_postgres.middleware import CurrentTask
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.events.models import TaskStatus
|
||||||
|
from authentik.events.system_tasks import SystemTask, prefill_task
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.tasks.models import Task
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
@ -36,9 +36,10 @@ def ensure_certificate_valid(body: str):
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Discover, import and update certificates from the filesystem."))
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
def certificate_discovery():
|
@prefill_task
|
||||||
self: Task = CurrentTask.get_task()
|
def certificate_discovery(self: SystemTask):
|
||||||
|
"""Discover, import and update certificates from the filesystem"""
|
||||||
certs = {}
|
certs = {}
|
||||||
private_keys = {}
|
private_keys = {}
|
||||||
discovered = 0
|
discovered = 0
|
||||||
@ -83,4 +84,6 @@ def certificate_discovery():
|
|||||||
dirty = True
|
dirty = True
|
||||||
if dirty:
|
if dirty:
|
||||||
cert.save()
|
cert.save()
|
||||||
self.info(f"Successfully imported {discovered} files.")
|
self.set_status(
|
||||||
|
TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=discovered))
|
||||||
|
)
|
||||||
|
@ -338,7 +338,7 @@ class TestCrypto(APITestCase):
|
|||||||
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
|
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
|
||||||
_key.write(builder.private_key)
|
_key.write(builder.private_key)
|
||||||
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
||||||
certificate_discovery.send()
|
certificate_discovery()
|
||||||
keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
|
keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
|
||||||
managed=MANAGED_DISCOVERED % "foo"
|
managed=MANAGED_DISCOVERED % "foo"
|
||||||
).first()
|
).first()
|
||||||
|
@ -3,8 +3,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.lib.utils.time import fqdn_rand
|
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseConfig(ManagedAppConfig):
|
class EnterpriseConfig(ManagedAppConfig):
|
||||||
@ -28,14 +26,3 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
|
|||||||
from authentik.enterprise.license import LicenseKey
|
from authentik.enterprise.license import LicenseKey
|
||||||
|
|
||||||
return LicenseKey.cached_summary().status.is_valid
|
return LicenseKey.cached_summary().status.is_valid
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.enterprise.tasks import enterprise_update_usage
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=enterprise_update_usage,
|
|
||||||
crontab=f"{fqdn_rand('enterprise_update_usage')} */2 * * *",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
"""authentik Unique Password policy app config"""
|
"""authentik Unique Password policy app config"""
|
||||||
|
|
||||||
from authentik.enterprise.apps import EnterpriseConfig
|
from authentik.enterprise.apps import EnterpriseConfig
|
||||||
from authentik.lib.utils.time import fqdn_rand
|
|
||||||
from authentik.tasks.schedules.lib import ScheduleSpec
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
|
class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
|
||||||
@ -10,21 +8,3 @@ class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
|
|||||||
label = "authentik_policies_unique_password"
|
label = "authentik_policies_unique_password"
|
||||||
verbose_name = "authentik Enterprise.Policies.Unique Password"
|
verbose_name = "authentik Enterprise.Policies.Unique Password"
|
||||||
default = True
|
default = True
|
||||||
|
|
||||||
@property
|
|
||||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
|
||||||
from authentik.enterprise.policies.unique_password.tasks import (
|
|
||||||
check_and_purge_password_history,
|
|
||||||
trim_password_histories,
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=trim_password_histories,
|
|
||||||
crontab=f"{fqdn_rand('policies_unique_password_trim')} */12 * * *",
|
|
||||||
),
|
|
||||||
ScheduleSpec(
|
|
||||||
actor=check_and_purge_password_history,
|
|
||||||
crontab=f"{fqdn_rand('policies_unique_password_purge')} */24 * * *",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
20
authentik/enterprise/policies/unique_password/settings.py
Normal file
20
authentik/enterprise/policies/unique_password/settings.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Unique Password Policy settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"policies_unique_password_trim_history": {
|
||||||
|
"task": "authentik.enterprise.policies.unique_password.tasks.trim_password_histories",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("policies_unique_password_trim"), hour="*/12"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
"policies_unique_password_check_purge": {
|
||||||
|
"task": (
|
||||||
|
"authentik.enterprise.policies.unique_password.tasks.check_and_purge_password_history"
|
||||||
|
),
|
||||||
|
"schedule": crontab(minute=fqdn_rand("policies_unique_password_purge"), hour="*/24"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
@ -1,37 +1,35 @@
|
|||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django_dramatiq_postgres.middleware import CurrentTask
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from authentik.enterprise.policies.unique_password.models import (
|
from authentik.enterprise.policies.unique_password.models import (
|
||||||
UniquePasswordPolicy,
|
UniquePasswordPolicy,
|
||||||
UserPasswordHistory,
|
UserPasswordHistory,
|
||||||
)
|
)
|
||||||
from authentik.tasks.models import Task
|
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
description=_(
|
@prefill_task
|
||||||
"Check if any UniquePasswordPolicy exists, and if not, purge the password history table."
|
def check_and_purge_password_history(self: SystemTask):
|
||||||
)
|
"""Check if any UniquePasswordPolicy exists, and if not, purge the password history table.
|
||||||
)
|
This is run on a schedule instead of being triggered by policy binding deletion.
|
||||||
def check_and_purge_password_history():
|
"""
|
||||||
self: Task = CurrentTask.get_task()
|
|
||||||
|
|
||||||
if not UniquePasswordPolicy.objects.exists():
|
if not UniquePasswordPolicy.objects.exists():
|
||||||
UserPasswordHistory.objects.all().delete()
|
UserPasswordHistory.objects.all().delete()
|
||||||
LOGGER.debug("Purged UserPasswordHistory table as no policies are in use")
|
LOGGER.debug("Purged UserPasswordHistory table as no policies are in use")
|
||||||
self.info("Successfully purged UserPasswordHistory")
|
self.set_status(TaskStatus.SUCCESSFUL, "Successfully purged UserPasswordHistory")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.info("Not purging password histories, a unique password policy exists")
|
self.set_status(
|
||||||
|
TaskStatus.SUCCESSFUL, "Not purging password histories, a unique password policy exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Remove user password history that are too old."))
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
def trim_password_histories():
|
def trim_password_histories(self: SystemTask):
|
||||||
"""Removes rows from UserPasswordHistory older than
|
"""Removes rows from UserPasswordHistory older than
|
||||||
the `n` most recent entries.
|
the `n` most recent entries.
|
||||||
|
|
||||||
@ -39,8 +37,6 @@ def trim_password_histories():
|
|||||||
UniquePasswordPolicy policies.
|
UniquePasswordPolicy policies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self: Task = CurrentTask.get_task()
|
|
||||||
|
|
||||||
# No policy, we'll let the cleanup above do its thing
|
# No policy, we'll let the cleanup above do its thing
|
||||||
if not UniquePasswordPolicy.objects.exists():
|
if not UniquePasswordPolicy.objects.exists():
|
||||||
return
|
return
|
||||||
@ -67,4 +63,4 @@ def trim_password_histories():
|
|||||||
|
|
||||||
num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete()
|
num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete()
|
||||||
LOGGER.debug("Deleted stale password history records", count=num_deleted)
|
LOGGER.debug("Deleted stale password history records", count=num_deleted)
|
||||||
self.info(f"Delete {num_deleted} stale password history records")
|
self.set_status(TaskStatus.SUCCESSFUL, f"Delete {num_deleted} stale password history records")
|
||||||
|
@ -76,7 +76,7 @@ class TestCheckAndPurgePasswordHistory(TestCase):
|
|||||||
self.assertTrue(UserPasswordHistory.objects.exists())
|
self.assertTrue(UserPasswordHistory.objects.exists())
|
||||||
|
|
||||||
# Run the task - should purge since no policy is in use
|
# Run the task - should purge since no policy is in use
|
||||||
check_and_purge_password_history.send()
|
check_and_purge_password_history()
|
||||||
|
|
||||||
# Verify the table is empty
|
# Verify the table is empty
|
||||||
self.assertFalse(UserPasswordHistory.objects.exists())
|
self.assertFalse(UserPasswordHistory.objects.exists())
|
||||||
@ -99,7 +99,7 @@ class TestCheckAndPurgePasswordHistory(TestCase):
|
|||||||
self.assertTrue(UserPasswordHistory.objects.exists())
|
self.assertTrue(UserPasswordHistory.objects.exists())
|
||||||
|
|
||||||
# Run the task - should NOT purge since a policy is in use
|
# Run the task - should NOT purge since a policy is in use
|
||||||
check_and_purge_password_history.send()
|
check_and_purge_password_history()
|
||||||
|
|
||||||
# Verify the entries still exist
|
# Verify the entries still exist
|
||||||
self.assertTrue(UserPasswordHistory.objects.exists())
|
self.assertTrue(UserPasswordHistory.objects.exists())
|
||||||
@ -119,17 +119,17 @@ class TestTrimPasswordHistory(TestCase):
|
|||||||
[
|
[
|
||||||
UserPasswordHistory(
|
UserPasswordHistory(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
old_password="hunter1", # nosec
|
old_password="hunter1", # nosec B106
|
||||||
created_at=_now - timedelta(days=3),
|
created_at=_now - timedelta(days=3),
|
||||||
),
|
),
|
||||||
UserPasswordHistory(
|
UserPasswordHistory(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
old_password="hunter2", # nosec
|
old_password="hunter2", # nosec B106
|
||||||
created_at=_now - timedelta(days=2),
|
created_at=_now - timedelta(days=2),
|
||||||
),
|
),
|
||||||
UserPasswordHistory(
|
UserPasswordHistory(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
old_password="hunter3", # nosec
|
old_password="hunter3", # nosec B106
|
||||||
created_at=_now,
|
created_at=_now,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -142,7 +142,7 @@ class TestTrimPasswordHistory(TestCase):
|
|||||||
enabled=True,
|
enabled=True,
|
||||||
order=0,
|
order=0,
|
||||||
)
|
)
|
||||||
trim_password_histories.send()
|
trim_password_histories.delay()
|
||||||
user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user)
|
user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user)
|
||||||
self.assertEqual(len(user_pwd_history_qs), 1)
|
self.assertEqual(len(user_pwd_history_qs), 1)
|
||||||
|
|
||||||
@ -159,7 +159,7 @@ class TestTrimPasswordHistory(TestCase):
|
|||||||
enabled=False,
|
enabled=False,
|
||||||
order=0,
|
order=0,
|
||||||
)
|
)
|
||||||
trim_password_histories.send()
|
trim_password_histories.delay()
|
||||||
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
|
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
def test_trim_password_history_fewer_records_than_maximum_is_no_op(self):
|
def test_trim_password_history_fewer_records_than_maximum_is_no_op(self):
|
||||||
@ -174,5 +174,5 @@ class TestTrimPasswordHistory(TestCase):
|
|||||||
enabled=True,
|
enabled=True,
|
||||||
order=0,
|
order=0,
|
||||||
)
|
)
|
||||||
trim_password_histories.send()
|
trim_password_histories.delay()
|
||||||
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
|
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
|
||||||
|
@ -55,5 +55,5 @@ class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixi
|
|||||||
]
|
]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
sync_task = google_workspace_sync
|
sync_single_task = google_workspace_sync
|
||||||
sync_objects_task = google_workspace_sync_objects
|
sync_objects_task = google_workspace_sync_objects
|
||||||
|
@ -7,7 +7,6 @@ from django.db import models
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dramatiq.actor import Actor
|
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
@ -111,12 +110,6 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
help_text=_("Property mappings used for group creation/updating."),
|
help_text=_("Property mappings used for group creation/updating."),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def sync_actor(self) -> Actor:
|
|
||||||
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
|
|
||||||
|
|
||||||
return google_workspace_sync
|
|
||||||
|
|
||||||
def client_for_model(
|
def client_for_model(
|
||||||
self,
|
self,
|
||||||
model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup],
|
model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup],
|
||||||
|
13
authentik/enterprise/providers/google_workspace/settings.py
Normal file
13
authentik/enterprise/providers/google_workspace/settings.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Google workspace provider task Settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"providers_google_workspace_sync": {
|
||||||
|
"task": "authentik.enterprise.providers.google_workspace.tasks.google_workspace_sync_all",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("google_workspace_sync_all"), hour="*/4"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
||||||
from authentik.enterprise.providers.google_workspace.tasks import (
|
from authentik.enterprise.providers.google_workspace.tasks import (
|
||||||
google_workspace_sync_direct_dispatch,
|
google_workspace_sync,
|
||||||
google_workspace_sync_m2m_dispatch,
|
google_workspace_sync_direct,
|
||||||
|
google_workspace_sync_m2m,
|
||||||
)
|
)
|
||||||
from authentik.lib.sync.outgoing.signals import register_signals
|
from authentik.lib.sync.outgoing.signals import register_signals
|
||||||
|
|
||||||
register_signals(
|
register_signals(
|
||||||
GoogleWorkspaceProvider,
|
GoogleWorkspaceProvider,
|
||||||
task_sync_direct_dispatch=google_workspace_sync_direct_dispatch,
|
task_sync_single=google_workspace_sync,
|
||||||
task_sync_m2m_dispatch=google_workspace_sync_m2m_dispatch,
|
task_sync_direct=google_workspace_sync_direct,
|
||||||
|
task_sync_m2m=google_workspace_sync_m2m,
|
||||||
)
|
)
|
||||||
|
@ -1,48 +1,37 @@
|
|||||||
"""Google Provider tasks"""
|
"""Google Provider tasks"""
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
|
|
||||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
||||||
|
from authentik.events.system_tasks import SystemTask
|
||||||
|
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
|
||||||
from authentik.lib.sync.outgoing.tasks import SyncTasks
|
from authentik.lib.sync.outgoing.tasks import SyncTasks
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
sync_tasks = SyncTasks(GoogleWorkspaceProvider)
|
sync_tasks = SyncTasks(GoogleWorkspaceProvider)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync Google Workspace provider objects."))
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
def google_workspace_sync_objects(*args, **kwargs):
|
def google_workspace_sync_objects(*args, **kwargs):
|
||||||
return sync_tasks.sync_objects(*args, **kwargs)
|
return sync_tasks.sync_objects(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Full sync for Google Workspace provider."))
|
@CELERY_APP.task(
|
||||||
def google_workspace_sync(provider_pk: int, *args, **kwargs):
|
base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True
|
||||||
|
)
|
||||||
|
def google_workspace_sync(self, provider_pk: int, *args, **kwargs):
|
||||||
"""Run full sync for Google Workspace provider"""
|
"""Run full sync for Google Workspace provider"""
|
||||||
return sync_tasks.sync(provider_pk, google_workspace_sync_objects)
|
return sync_tasks.sync_single(self, provider_pk, google_workspace_sync_objects)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync a direct object (user, group) for Google Workspace provider."))
|
@CELERY_APP.task()
|
||||||
|
def google_workspace_sync_all():
|
||||||
|
return sync_tasks.sync_all(google_workspace_sync)
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
def google_workspace_sync_direct(*args, **kwargs):
|
def google_workspace_sync_direct(*args, **kwargs):
|
||||||
return sync_tasks.sync_signal_direct(*args, **kwargs)
|
return sync_tasks.sync_signal_direct(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
description=_(
|
|
||||||
"Dispatch syncs for a direct object (user, group) for Google Workspace providers."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
def google_workspace_sync_direct_dispatch(*args, **kwargs):
|
|
||||||
return sync_tasks.sync_signal_direct_dispatch(google_workspace_sync_direct, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync a related object (memberships) for Google Workspace provider."))
|
|
||||||
def google_workspace_sync_m2m(*args, **kwargs):
|
def google_workspace_sync_m2m(*args, **kwargs):
|
||||||
return sync_tasks.sync_signal_m2m(*args, **kwargs)
|
return sync_tasks.sync_signal_m2m(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
|
||||||
description=_(
|
|
||||||
"Dispatch syncs for a related object (memberships) for Google Workspace providers."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
def google_workspace_sync_m2m_dispatch(*args, **kwargs):
|
|
||||||
return sync_tasks.sync_signal_m2m_dispatch(google_workspace_sync_m2m, *args, **kwargs)
|
|
||||||
|
@ -324,7 +324,7 @@ class GoogleWorkspaceGroupTests(TestCase):
|
|||||||
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
|
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
|
||||||
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
|
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
|
||||||
):
|
):
|
||||||
google_workspace_sync.send(self.provider.pk).get_result()
|
google_workspace_sync.delay(self.provider.pk).get()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
GoogleWorkspaceProviderGroup.objects.filter(
|
GoogleWorkspaceProviderGroup.objects.filter(
|
||||||
group=different_group, provider=self.provider
|
group=different_group, provider=self.provider
|
||||||
|
@ -302,7 +302,7 @@ class GoogleWorkspaceUserTests(TestCase):
|
|||||||
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
|
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
|
||||||
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
|
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
|
||||||
):
|
):
|
||||||
google_workspace_sync.send(self.provider.pk).get_result()
|
google_workspace_sync.delay(self.provider.pk).get()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
GoogleWorkspaceProviderUser.objects.filter(
|
GoogleWorkspaceProviderUser.objects.filter(
|
||||||
user=different_user, provider=self.provider
|
user=different_user, provider=self.provider
|
||||||
|
@ -53,5 +53,5 @@ class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin
|
|||||||
]
|
]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
sync_task = microsoft_entra_sync
|
sync_single_task = microsoft_entra_sync
|
||||||
sync_objects_task = microsoft_entra_sync_objects
|
sync_objects_task = microsoft_entra_sync_objects
|
||||||
|
@ -8,7 +8,6 @@ from django.db import models
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dramatiq.actor import Actor
|
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
@ -100,12 +99,6 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
help_text=_("Property mappings used for group creation/updating."),
|
help_text=_("Property mappings used for group creation/updating."),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def sync_actor(self) -> Actor:
|
|
||||||
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync
|
|
||||||
|
|
||||||
return microsoft_entra_sync
|
|
||||||
|
|
||||||
def client_for_model(
|
def client_for_model(
|
||||||
self,
|
self,
|
||||||
model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup],
|
model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup],
|
||||||
|
13
authentik/enterprise/providers/microsoft_entra/settings.py
Normal file
13
authentik/enterprise/providers/microsoft_entra/settings.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Microsoft Entra provider task Settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"providers_microsoft_entra_sync": {
|
||||||
|
"task": "authentik.enterprise.providers.microsoft_entra.tasks.microsoft_entra_sync_all",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("microsoft_entra_sync_all"), hour="*/4"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
||||||
from authentik.enterprise.providers.microsoft_entra.tasks import (
|
from authentik.enterprise.providers.microsoft_entra.tasks import (
|
||||||
microsoft_entra_sync_direct_dispatch,
|
microsoft_entra_sync,
|
||||||
microsoft_entra_sync_m2m_dispatch,
|
microsoft_entra_sync_direct,
|
||||||
|
microsoft_entra_sync_m2m,
|
||||||
)
|
)
|
||||||
from authentik.lib.sync.outgoing.signals import register_signals
|
from authentik.lib.sync.outgoing.signals import register_signals
|
||||||
|
|
||||||
register_signals(
|
register_signals(
|
||||||
MicrosoftEntraProvider,
|
MicrosoftEntraProvider,
|
||||||
task_sync_direct_dispatch=microsoft_entra_sync_direct_dispatch,
|
task_sync_single=microsoft_entra_sync,
|
||||||
task_sync_m2m_dispatch=microsoft_entra_sync_m2m_dispatch,
|
task_sync_direct=microsoft_entra_sync_direct,
|
||||||
|
task_sync_m2m=microsoft_entra_sync_m2m,
|
||||||
)
|
)
|
||||||
|
@ -1,46 +1,37 @@
|
|||||||
"""Microsoft Entra Provider tasks"""
|
"""Microsoft Entra Provider tasks"""
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from dramatiq.actor import actor
|
|
||||||
|
|
||||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
||||||
|
from authentik.events.system_tasks import SystemTask
|
||||||
|
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
|
||||||
from authentik.lib.sync.outgoing.tasks import SyncTasks
|
from authentik.lib.sync.outgoing.tasks import SyncTasks
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
sync_tasks = SyncTasks(MicrosoftEntraProvider)
|
sync_tasks = SyncTasks(MicrosoftEntraProvider)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync Microsoft Entra provider objects."))
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
def microsoft_entra_sync_objects(*args, **kwargs):
|
def microsoft_entra_sync_objects(*args, **kwargs):
|
||||||
return sync_tasks.sync_objects(*args, **kwargs)
|
return sync_tasks.sync_objects(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Full sync for Microsoft Entra provider."))
|
@CELERY_APP.task(
|
||||||
def microsoft_entra_sync(provider_pk: int, *args, **kwargs):
|
base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True
|
||||||
|
)
|
||||||
|
def microsoft_entra_sync(self, provider_pk: int, *args, **kwargs):
|
||||||
"""Run full sync for Microsoft Entra provider"""
|
"""Run full sync for Microsoft Entra provider"""
|
||||||
return sync_tasks.sync(provider_pk, microsoft_entra_sync_objects)
|
return sync_tasks.sync_single(self, provider_pk, microsoft_entra_sync_objects)
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync a direct object (user, group) for Microsoft Entra provider."))
|
@CELERY_APP.task()
|
||||||
|
def microsoft_entra_sync_all():
|
||||||
|
return sync_tasks.sync_all(microsoft_entra_sync)
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
def microsoft_entra_sync_direct(*args, **kwargs):
|
def microsoft_entra_sync_direct(*args, **kwargs):
|
||||||
return sync_tasks.sync_signal_direct(*args, **kwargs)
|
return sync_tasks.sync_signal_direct(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
|
||||||
description=_("Dispatch syncs for a direct object (user, group) for Microsoft Entra providers.")
|
|
||||||
)
|
|
||||||
def microsoft_entra_sync_direct_dispatch(*args, **kwargs):
|
|
||||||
return sync_tasks.sync_signal_direct_dispatch(microsoft_entra_sync_direct, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@actor(description=_("Sync a related object (memberships) for Microsoft Entra provider."))
|
|
||||||
def microsoft_entra_sync_m2m(*args, **kwargs):
|
def microsoft_entra_sync_m2m(*args, **kwargs):
|
||||||
return sync_tasks.sync_signal_m2m(*args, **kwargs)
|
return sync_tasks.sync_signal_m2m(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
|
||||||
description=_(
|
|
||||||
"Dispatch syncs for a related object (memberships) for Microsoft Entra providers."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
def microsoft_entra_sync_m2m_dispatch(*args, **kwargs):
|
|
||||||
return sync_tasks.sync_signal_m2m_dispatch(microsoft_entra_sync_m2m, *args, **kwargs)
|
|
||||||
|
@ -252,13 +252,9 @@ class MicrosoftEntraGroupTests(TestCase):
|
|||||||
member_add.assert_called_once()
|
member_add.assert_called_once()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
member_add.call_args[0][0].odata_id,
|
member_add.call_args[0][0].odata_id,
|
||||||
f"https://graph.microsoft.com/v1.0/directoryObjects/{
|
f"https://graph.microsoft.com/v1.0/directoryObjects/{MicrosoftEntraProviderUser.objects.filter(
|
||||||
MicrosoftEntraProviderUser.objects.filter(
|
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
)
|
).first().microsoft_id}",
|
||||||
.first()
|
|
||||||
.microsoft_id
|
|
||||||
}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_group_create_member_remove(self):
|
def test_group_create_member_remove(self):
|
||||||
@ -315,13 +311,9 @@ class MicrosoftEntraGroupTests(TestCase):
|
|||||||
member_add.assert_called_once()
|
member_add.assert_called_once()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
member_add.call_args[0][0].odata_id,
|
member_add.call_args[0][0].odata_id,
|
||||||
f"https://graph.microsoft.com/v1.0/directoryObjects/{
|
f"https://graph.microsoft.com/v1.0/directoryObjects/{MicrosoftEntraProviderUser.objects.filter(
|
||||||
MicrosoftEntraProviderUser.objects.filter(
|
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
)
|
).first().microsoft_id}",
|
||||||
.first()
|
|
||||||
.microsoft_id
|
|
||||||
}",
|
|
||||||
)
|
)
|
||||||
member_remove.assert_called_once()
|
member_remove.assert_called_once()
|
||||||
|
|
||||||
@ -421,7 +413,7 @@ class MicrosoftEntraGroupTests(TestCase):
|
|||||||
),
|
),
|
||||||
) as group_list,
|
) as group_list,
|
||||||
):
|
):
|
||||||
microsoft_entra_sync.send(self.provider.pk).get_result()
|
microsoft_entra_sync.delay(self.provider.pk).get()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
MicrosoftEntraProviderGroup.objects.filter(
|
MicrosoftEntraProviderGroup.objects.filter(
|
||||||
group=different_group, provider=self.provider
|
group=different_group, provider=self.provider
|
||||||
|
@ -397,7 +397,7 @@ class MicrosoftEntraUserTests(APITestCase):
|
|||||||
AsyncMock(return_value=GroupCollectionResponse(value=[])),
|
AsyncMock(return_value=GroupCollectionResponse(value=[])),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
microsoft_entra_sync.send(self.provider.pk).get_result()
|
microsoft_entra_sync.delay(self.provider.pk).get()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
MicrosoftEntraProviderUser.objects.filter(
|
MicrosoftEntraProviderUser.objects.filter(
|
||||||
user=different_user, provider=self.provider
|
user=different_user, provider=self.provider
|
||||||
|
@ -17,7 +17,6 @@ from authentik.crypto.models import CertificateKeyPair
|
|||||||
from authentik.lib.models import CreatedUpdatedModel
|
from authentik.lib.models import CreatedUpdatedModel
|
||||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||||
from authentik.tasks.models import TasksModel
|
|
||||||
|
|
||||||
|
|
||||||
class EventTypes(models.TextChoices):
|
class EventTypes(models.TextChoices):
|
||||||
@ -43,7 +42,7 @@ class SSFEventStatus(models.TextChoices):
|
|||||||
SENT = "sent"
|
SENT = "sent"
|
||||||
|
|
||||||
|
|
||||||
class SSFProvider(TasksModel, BackchannelProvider):
|
class SSFProvider(BackchannelProvider):
|
||||||
"""Shared Signals Framework provider to allow applications to
|
"""Shared Signals Framework provider to allow applications to
|
||||||
receive user events from authentik."""
|
receive user events from authentik."""
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user